From 93d3f86c2b3450299651c1aea9b2e5bbc1e4fa37 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 26 May 2026 15:18:31 +0000 Subject: [PATCH] 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 --- .env.example | 2 +- .gitignore | 5 + AGENTS.md | 109 +++ CHANGELOG.md | 12 + CLAUDE.md | 4 +- CURRENT.md | 10 + README.md | 8 +- apps/coder/.env.host | 1 + apps/coder/src/index.ts | 40 + apps/coder/src/routes/arena.ts | 24 +- apps/coder/src/routes/chat-resolve.ts | 81 ++ apps/coder/src/routes/messages.ts | 122 ++- apps/coder/src/routes/providers.ts | 81 +- apps/coder/src/routes/skills.ts | 93 +++ apps/coder/src/routes/tasks.ts | 58 +- apps/coder/src/routes/ws.ts | 2 +- apps/coder/src/schema.sql | 5 + .../src/services/__tests__/acp-derive.test.ts | 154 ++++ .../__tests__/acp-tool-snapshot.test.ts | 66 ++ .../services/__tests__/cursor-models.test.ts | 47 ++ .../__tests__/provider-commands.test.ts | 26 + .../__tests__/provider-snapshot.test.ts | 168 ++++ apps/coder/src/services/acp-client-fs.ts | 35 + apps/coder/src/services/acp-derive.ts | 128 +++ apps/coder/src/services/acp-dispatch.ts | 430 +++++++---- apps/coder/src/services/acp-probe.ts | 155 ++++ apps/coder/src/services/acp-spawn.ts | 29 + apps/coder/src/services/acp-stream.ts | 44 ++ apps/coder/src/services/acp-tool-snapshot.ts | 120 +++ .../src/services/agent-commands-cache.ts | 28 + apps/coder/src/services/agent-probe.ts | 104 ++- apps/coder/src/services/agent-turn-persist.ts | 56 ++ apps/coder/src/services/cursor-models.ts | 39 + apps/coder/src/services/dispatcher.ts | 115 ++- apps/coder/src/services/host-exec.ts | 66 ++ apps/coder/src/services/mcp-server.ts | 43 +- apps/coder/src/services/permission-waiter.ts | 113 +++ apps/coder/src/services/provider-commands.ts | 84 ++ apps/coder/src/services/provider-manifest.ts | 108 +++ apps/coder/src/services/provider-registry.ts | 37 +- apps/coder/src/services/provider-snapshot.ts | 266 +++++++ apps/coder/src/services/provider-types.ts | 51 ++ apps/coder/src/services/pty-dispatch.ts | 61 +- apps/coder/src/services/qwen-settings.ts | 21 + apps/coder/src/services/ssh.ts | 129 ---- apps/coder/src/services/worktrees.ts | 18 +- apps/server/package.json | 4 +- apps/server/src/index.ts | 33 +- apps/server/src/routes/coder-proxy.ts | 91 +++ apps/server/src/routes/sessions.ts | 8 +- apps/server/src/routes/skills.ts | 97 +-- .../src/services/__tests__/agents.test.ts | 33 + .../src/services/__tests__/inference.test.ts | 70 ++ apps/server/src/services/agents.ts | 10 +- apps/server/src/services/inference/payload.ts | 51 +- apps/server/src/services/skill-invoke.ts | 148 ++++ apps/server/src/services/skills.ts | 2 +- apps/server/src/types/ws-frames.ts | 46 ++ apps/web/src/api/client.ts | 49 +- apps/web/src/api/types.ts | 83 +- apps/web/src/api/ws-frames.ts | 46 ++ apps/web/src/components/AgentCommandsHint.tsx | 39 + apps/web/src/components/AgentComposerBar.tsx | 308 ++++++++ apps/web/src/components/ChatInput.tsx | 28 +- apps/web/src/components/ChatTabBar.tsx | 6 +- apps/web/src/components/MessageBubble.tsx | 23 +- apps/web/src/components/NewPaneMenu.tsx | 6 +- apps/web/src/components/PermissionCard.tsx | 49 ++ apps/web/src/components/ProjectSidebar.tsx | 9 +- apps/web/src/components/ProviderPicker.tsx | 178 ----- apps/web/src/components/SkillSlashCommand.tsx | 221 ------ .../web/src/components/SlashCommandPicker.tsx | 181 +++++ apps/web/src/components/Workspace.tsx | 133 ++-- .../src/components/panes/CoderMessageList.tsx | 228 ++++++ apps/web/src/components/panes/CoderPane.tsx | 653 ++++++++++++---- apps/web/src/hooks/useProviderSnapshot.ts | 49 ++ apps/web/src/hooks/useSessionStream.ts | 11 +- apps/web/src/hooks/useWorkspacePanes.ts | 144 +++- apps/web/src/lib/apply-user-delta.ts | 11 + apps/web/src/lib/coder-session.ts | 18 + apps/web/src/lib/coder-tools.ts | 68 ++ apps/web/src/lib/slash-command.ts | 29 + apps/web/src/pages/Project.tsx | 9 +- apps/web/src/pages/Session.tsx | 8 +- boocode_code_review.md | 31 +- boocode_roadmap.md | 24 +- .../improving-boocode-guidance/SKILL.md | 6 +- docs/ARCHITECTURE.md | 122 +++ docs/DEFERRED-WORK.md | 312 ++++++++ docs/STALE-DEPRECATED.md | 77 ++ .../2026-05-25-provider-picker-backend.md | 4 +- openspec/README.md | 2 + .../changes/archived/v2.2-paseo-providers.md | 5 + .../changes/v2-3-provider-lifecycle/design.md | 726 ++++++++++++++++++ .../v2-3-provider-lifecycle/proposal.md | 61 ++ .../changes/v2-3-provider-lifecycle/tasks.md | 75 ++ 96 files changed, 6694 insertions(+), 1329 deletions(-) create mode 100644 AGENTS.md create mode 100644 CURRENT.md create mode 100644 apps/coder/src/routes/chat-resolve.ts create mode 100644 apps/coder/src/routes/skills.ts create mode 100644 apps/coder/src/services/__tests__/acp-derive.test.ts create mode 100644 apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts create mode 100644 apps/coder/src/services/__tests__/cursor-models.test.ts create mode 100644 apps/coder/src/services/__tests__/provider-commands.test.ts create mode 100644 apps/coder/src/services/__tests__/provider-snapshot.test.ts create mode 100644 apps/coder/src/services/acp-client-fs.ts create mode 100644 apps/coder/src/services/acp-derive.ts create mode 100644 apps/coder/src/services/acp-probe.ts create mode 100644 apps/coder/src/services/acp-spawn.ts create mode 100644 apps/coder/src/services/acp-stream.ts create mode 100644 apps/coder/src/services/acp-tool-snapshot.ts create mode 100644 apps/coder/src/services/agent-commands-cache.ts create mode 100644 apps/coder/src/services/agent-turn-persist.ts create mode 100644 apps/coder/src/services/cursor-models.ts create mode 100644 apps/coder/src/services/host-exec.ts create mode 100644 apps/coder/src/services/permission-waiter.ts create mode 100644 apps/coder/src/services/provider-commands.ts create mode 100644 apps/coder/src/services/provider-manifest.ts create mode 100644 apps/coder/src/services/provider-snapshot.ts create mode 100644 apps/coder/src/services/provider-types.ts create mode 100644 apps/coder/src/services/qwen-settings.ts delete mode 100644 apps/coder/src/services/ssh.ts create mode 100644 apps/server/src/routes/coder-proxy.ts create mode 100644 apps/server/src/services/__tests__/agents.test.ts create mode 100644 apps/server/src/services/skill-invoke.ts create mode 100644 apps/web/src/components/AgentCommandsHint.tsx create mode 100644 apps/web/src/components/AgentComposerBar.tsx create mode 100644 apps/web/src/components/PermissionCard.tsx delete mode 100644 apps/web/src/components/ProviderPicker.tsx delete mode 100644 apps/web/src/components/SkillSlashCommand.tsx create mode 100644 apps/web/src/components/SlashCommandPicker.tsx create mode 100644 apps/web/src/components/panes/CoderMessageList.tsx create mode 100644 apps/web/src/hooks/useProviderSnapshot.ts create mode 100644 apps/web/src/lib/apply-user-delta.ts create mode 100644 apps/web/src/lib/coder-session.ts create mode 100644 apps/web/src/lib/coder-tools.ts create mode 100644 apps/web/src/lib/slash-command.ts create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEFERRED-WORK.md create mode 100644 docs/STALE-DEPRECATED.md create mode 100644 openspec/changes/archived/v2.2-paseo-providers.md create mode 100644 openspec/changes/v2-3-provider-lifecycle/design.md create mode 100644 openspec/changes/v2-3-provider-lifecycle/proposal.md create mode 100644 openspec/changes/v2-3-provider-lifecycle/tasks.md diff --git a/.env.example b/.env.example index 465da34..c09d710 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ NODE_ENV=production PORT=3000 -DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode +DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boochat LLAMA_SWAP_URL=http://100.101.41.16:8401 PROJECT_ROOT_WHITELIST=/opt BOOTSTRAP_ROOT=/opt/projects diff --git a/.gitignore b/.gitignore index 4d31f51..4a99dba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ node_modules dist .env + +# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked) +.claude/ +.cursor/ +.cursorignore CLAUDE.local.md *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6e8270f --- /dev/null +++ b/AGENTS.md @@ -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//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//` (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//proposal.md @openspec/changes//tasks.md +Implement tasks 1–3. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec3fb3..9bc087a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## 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. diff --git a/CLAUDE.md b/CLAUDE.md index a73f303..d99c9af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. + ## What is BooCode Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side). @@ -133,7 +135,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0 BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`. - `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls. -- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; dispatch via SSH). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch. +- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; 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 diff --git a/CURRENT.md b/CURRENT.md new file mode 100644 index 0000000..3d49d91 --- /dev/null +++ b/CURRENT.md @@ -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. diff --git a/README.md b/README.md index 589cacf..b06fd41 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals). +**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) + ## Stack - Node 20, Fastify, postgres (porsager/postgres), ws, zod @@ -30,7 +32,7 @@ cp .env.example .env docker compose up -d boocode_db # run server (port 3000) and web (port 5173) in two shells -DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \ +DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \ LLAMA_SWAP_URL=http://100.101.41.16:8401 \ pnpm dev:server @@ -58,8 +60,8 @@ upstream and inject `Remote-User`. Postgres binds loopback only. |BooChat|`100.114.205.53:9500`|Read-only chat + SPA | |BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes | |BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) | -|Postgres|`127.0.0.1:5500`|Shared database (`boochat_db`) | -|codecontext|`:8765` (internal)|MCP server for architect tools | +|Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) | +|codecontext|internal `:8080`|Code graph sidecar (Docker network only) | ## What's shipped diff --git a/apps/coder/.env.host b/apps/coder/.env.host index 46900c9..4c8bc25 100644 --- a/apps/coder/.env.host +++ b/apps/coder/.env.host @@ -12,3 +12,4 @@ 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 diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 6345e55..e19df75 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -23,6 +23,7 @@ import { adaptWriteTool } from './services/tools/adapter.js'; import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js'; // Routes import { registerMessageRoutes } from './routes/messages.js'; +import { registerSkillRoutes } from './routes/skills.js'; import { registerPendingRoutes } from './routes/pending.js'; import { registerTaskRoutes } from './routes/tasks.js'; import { registerInboxRoutes } from './routes/inbox.js'; @@ -33,6 +34,9 @@ import { registerWebSocket } from './routes/ws.js'; // Phase 4: dispatcher + agent probe import { createDispatcher } from './services/dispatcher.js'; import { probeAgents } from './services/agent-probe.js'; +import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js'; +import { setPermissionHooks } from './services/permission-waiter.js'; +import { homedir } from 'node:os'; async function main() { // MCP mode: stdio transport, no HTTP server @@ -72,6 +76,31 @@ async function main() { // Broker: in-memory pub/sub for session + user channel streaming. const broker = createBroker(app.log); + setPermissionHooks({ + onPrompt: async (prompt) => { + await sql` + UPDATE tasks SET state = 'blocked' WHERE id = ${prompt.taskId} AND state = 'running' + `; + broker.publishFrame(prompt.sessionId, { + type: 'permission_requested', + task_id: prompt.taskId, + session_id: prompt.sessionId, + tool_title: prompt.toolTitle, + options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })), + } as WsFrame); + }, + onResolved: async (taskId, sessionId) => { + await sql` + UPDATE tasks SET state = 'running' WHERE id = ${taskId} AND state = 'blocked' + `; + broker.publishFrame(sessionId, { + type: 'permission_resolved', + task_id: taskId, + session_id: sessionId, + } as WsFrame); + }, + }); + // --- Tool registry extension --- // Append BooCoder write tools (adapted to BooChat's ToolDef interface) to // the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds @@ -134,6 +163,16 @@ async function main() { // Phase 4: probe available agents on startup await probeAgents(sql, app.log); + // Warm provider snapshot in background (ACP cold probes + model merges) + void getProviderSnapshot(sql, config, homedir(), true) + .then((entries) => persistProbedModels(sql, entries, app.log)) + .catch((err) => { + app.log.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'provider-snapshot: warm failed', + ); + }); + // Phase 4: dispatcher — polls tasks table and runs inference const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config }); dispatcher.start(); @@ -141,6 +180,7 @@ async function main() { // Register routes registerMessageRoutes(app, sql, broker, inferenceApi); + registerSkillRoutes(app, sql, broker, inferenceApi); registerPendingRoutes(app, sql); registerTaskRoutes(app, sql, inferenceApi); registerInboxRoutes(app, sql); diff --git a/apps/coder/src/routes/arena.ts b/apps/coder/src/routes/arena.ts index 9983cd5..8757fb1 100644 --- a/apps/coder/src/routes/arena.ts +++ b/apps/coder/src/routes/arena.ts @@ -12,6 +12,8 @@ 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({ @@ -24,6 +26,8 @@ interface TaskRow { id: string; agent: string | null; model: string | null; + mode_id: string | null; + thinking_option_id: string | null; state: string; } @@ -42,9 +46,17 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { const tasks: TaskRow[] = []; for (const contestant of contestants) { const [task] = await sql` - INSERT INTO tasks (project_id, input, agent, model, arena_id) - VALUES (${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${arenaId}) - RETURNING id, agent, model, state + 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!); } @@ -52,10 +64,12 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { reply.code(201); return { arena_id: arenaId, - tasks: tasks.map(t => ({ + 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, })), }; @@ -73,7 +87,7 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void { } const tasks = await sql` - SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at, arena_id + SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id FROM tasks WHERE arena_id = ${arena_id} ORDER BY created_at diff --git a/apps/coder/src/routes/chat-resolve.ts b/apps/coder/src/routes/chat-resolve.ts new file mode 100644 index 0000000..075c27d --- /dev/null +++ b/apps/coder/src/routes/chat-resolve.ts @@ -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 { + 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; + }); +} diff --git a/apps/coder/src/routes/messages.ts b/apps/coder/src/routes/messages.ts index 8dc9f06..8405ee1 100644 --- a/apps/coder/src/routes/messages.ts +++ b/apps/coder/src/routes/messages.ts @@ -3,12 +3,16 @@ import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Broker } from '@boocode/server/broker'; import type { WsFrame } from '@boocode/server/ws-frames'; +import { resolveChatId } from './chat-resolve.js'; const SendBody = z.object({ content: z.string().min(1).max(64_000), + 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 { @@ -17,12 +21,100 @@ interface InferenceApi { hasActive: (chatId: string) => boolean; } +interface MessageRow { + id: string; + role: string; + content: string | null; + status: string | null; + tool_calls: Array<{ id: string; name: string; args?: Record }> | null; + tool_results: { + tool_call_id: string; + output: unknown; + truncated?: boolean; + error?: string; + } | null; + reasoning_parts: Array<{ text?: string }> | null; +} + +function mapCoderMessageRow(row: MessageRow) { + if (row.role === 'tool') { + if (!row.tool_results?.tool_call_id) return null; + return { + id: row.id, + role: 'tool' as const, + tool_results: row.tool_results, + }; + } + if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') { + return null; + } + const tool_calls = row.tool_calls?.map((tc) => ({ + id: tc.id, + function: { + name: tc.name, + arguments: JSON.stringify(tc.args ?? {}), + }, + })); + const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? ''; + return { + id: row.id, + role: row.role as 'user' | 'assistant' | 'system', + content: row.content ?? '', + status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed', + ...(reasoningText ? { reasoning_text: reasoningText } : {}), + ...(tool_calls?.length ? { tool_calls } : {}), + }; +} + export function registerMessageRoutes( app: FastifyInstance, sql: Sql, broker: Broker, inference: InferenceApi, ): void { + // GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect + app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>( + '/api/sessions/:sessionId/messages', + async (req, reply) => { + const sessionId = req.params.sessionId; + const chatId = req.query.chat_id; + const sessionRows = await sql<{ id: string }[]>` + SELECT id FROM sessions WHERE id = ${sessionId} + `; + if (sessionRows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + if (chatId) { + const chatRows = await sql<{ id: string }[]>` + SELECT id FROM chats + WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open' + `; + if (chatRows.length === 0) { + reply.code(404); + return { error: 'chat not found or not open in this session' }; + } + } + + const rows = chatId + ? await sql` + 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` + SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts + FROM messages_with_parts + WHERE session_id = ${sessionId} + ORDER BY created_at ASC, id ASC + `; + + return rows.map(mapCoderMessageRow).filter((m) => m !== null); + }, + ); + // POST /api/sessions/:sessionId/messages — send a user message + kick off inference app.post<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/messages', @@ -34,7 +126,8 @@ export function registerMessageRoutes( } const sessionId = req.params.sessionId; - const { content, chat_id: explicitChatId, provider, model } = parsed.data; + const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } = + parsed.data; const isExternal = provider && provider !== 'boocode'; // Validate session exists @@ -46,8 +139,13 @@ export function registerMessageRoutes( return { error: 'session not found' }; } - // Resolve chat_id: use explicit value or find/create a default chat - let chatId: string; + const resolved = await resolveChatId(sql, sessionId, pane_id); + if (!resolved) { + reply.code(404); + return { error: 'pane not found' }; + } + + let chatId = resolved; if (explicitChatId) { const chatRows = await sql<{ id: string }[]>` SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open' @@ -57,20 +155,6 @@ export function registerMessageRoutes( return { error: 'chat not found or not open in this session' }; } chatId = explicitChatId; - } else { - const existing = await sql<{ id: string }[]>` - SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at LIMIT 1 - `; - if (existing.length > 0) { - chatId = existing[0]!.id; - } else { - const [newChat] = await sql<{ id: string }[]>` - INSERT INTO chats (session_id, name, status) - VALUES (${sessionId}, 'Chat', 'open') - RETURNING id - `; - chatId = newChat!.id; - } } if (!isExternal) { @@ -113,8 +197,8 @@ export function registerMessageRoutes( // External provider: create a task for the dispatcher const projectId = sessionRows[0]!.project_id; const [task] = await sql<{ id: string; state: string }[]>` - INSERT INTO tasks (project_id, input, agent, model, session_id) - VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${sessionId}) + 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); diff --git a/apps/coder/src/routes/providers.ts b/apps/coder/src/routes/providers.ts index f7bc3b9..a1be209 100644 --- a/apps/coder/src/routes/providers.ts +++ b/apps/coder/src/routes/providers.ts @@ -1,80 +1,17 @@ import type { FastifyInstance } from 'fastify'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; -import { PROVIDERS } from '../services/provider-registry.js'; - -interface ProviderModel { - id: string; - label: string; -} - -interface ProviderResponse { - name: string; - label: string; - transport: string; - installed: boolean; - models: ProviderModel[]; -} - -interface LlamaSwapModel { - id: string; - [key: string]: unknown; -} - -async function fetchLlamaSwapModels(config: Config): Promise { - try { - const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`); - if (!res.ok) return []; - const parsed = (await res.json()) as { data?: LlamaSwapModel[] }; - return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id })); - } catch { - return []; - } -} +import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js'; export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void { - app.get('/api/providers', async (_req, _reply) => { - const llamaModels = await fetchLlamaSwapModels(config); + app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => { + const cwd = req.query.cwd; + return getProviderSnapshot(sql, config, cwd); + }); - const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>` - SELECT name, models, label, transport, supports_acp FROM available_agents - `; - const agentMap = new Map(agents.map((a) => [a.name, a])); - - const result: ProviderResponse[] = []; - - for (const provider of PROVIDERS) { - const isNative = provider.name === 'boocode'; - const agentRow = agentMap.get(provider.name); - const installed = isNative || !!agentRow; - - if (!installed) continue; - - let models: ProviderModel[]; - if (provider.modelSource === 'llama-swap') { - models = llamaModels; - } else if (agentRow?.models && agentRow.models.length > 0) { - models = agentRow.models; - } else if (provider.staticModels) { - models = provider.staticModels; - } else { - models = []; - } - - let transport: string = provider.transport; - if (agentRow) { - transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport; - } - - result.push({ - name: provider.name, - label: agentRow?.label ?? provider.label, - transport, - installed, - models, - }); - } - - return result; + app.post('/api/providers/refresh', async (_req, _reply) => { + clearProviderSnapshotCache(); + const entries = await getProviderSnapshot(sql, config, undefined, true); + return { refreshed: entries.length }; }); } diff --git a/apps/coder/src/routes/skills.ts b/apps/coder/src/routes/skills.ts new file mode 100644 index 0000000..c195d41 --- /dev/null +++ b/apps/coder/src/routes/skills.ts @@ -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; + }, + ); +} diff --git a/apps/coder/src/routes/tasks.ts b/apps/coder/src/routes/tasks.ts index 021d1e3..f7553d9 100644 --- a/apps/coder/src/routes/tasks.ts +++ b/apps/coder/src/routes/tasks.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; +import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js'; +import { getTaskCommands } from '../services/agent-commands-cache.js'; interface InferenceApi { cancel: (sessionId: string, chatId: string) => Promise; @@ -11,6 +13,12 @@ const CreateBody = z.object({ input: z.string().min(1).max(64_000), agent: z.string().max(100).optional(), model: z.string().max(200).optional(), + mode_id: z.string().max(200).optional(), + thinking_option_id: z.string().max(200).optional(), +}); + +const PermissionBody = z.object({ + option_id: z.string().max(200).nullable(), }); const ListQuery = z.object({ @@ -27,11 +35,11 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In 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 }[]>` - INSERT INTO tasks (project_id, input, agent, model) - VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}) + INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id) + VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}) RETURNING id, state `; @@ -111,13 +119,15 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In } const task = rows[0]!; - if (task.state !== 'pending' && task.state !== 'running') { + if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') { reply.code(409); return { error: `cannot cancel task in state '${task.state}'` }; } + cancelPendingPermission(taskId); + // If running, try to cancel inference - if (task.state === 'running' && task.session_id) { + if ((task.state === 'running' || task.state === 'blocked') && task.session_id) { // Find active chat in the task's session const chats = await sql<{ id: string }[]>` SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open' @@ -130,9 +140,45 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In await sql` UPDATE tasks 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 }; }); + + // 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 }; + }); } diff --git a/apps/coder/src/routes/ws.ts b/apps/coder/src/routes/ws.ts index e0e8f2a..133cc92 100644 --- a/apps/coder/src/routes/ws.ts +++ b/apps/coder/src/routes/ws.ts @@ -25,7 +25,7 @@ export function registerWebSocket( // Send snapshot of existing messages so client can hydrate const messages = await sql[]>` - SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, + SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, summary, tail_start_id, compacted_at FROM messages_with_parts diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 8473ccd..7edb8b9 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -66,3 +66,8 @@ CREATE OR REPLACE VIEW human_inbox AS ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS 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; diff --git a/apps/coder/src/services/__tests__/acp-derive.test.ts b/apps/coder/src/services/__tests__/acp-derive.test.ts new file mode 100644 index 0000000..841bb55 --- /dev/null +++ b/apps/coder/src/services/__tests__/acp-derive.test.ts @@ -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(); + }); +}); diff --git a/apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts b/apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts new file mode 100644 index 0000000..00117dd --- /dev/null +++ b/apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts @@ -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'); + }); +}); diff --git a/apps/coder/src/services/__tests__/cursor-models.test.ts b/apps/coder/src/services/__tests__/cursor-models.test.ts new file mode 100644 index 0000000..ba402ec --- /dev/null +++ b/apps/coder/src/services/__tests__/cursor-models.test.ts @@ -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 }]); + }); +}); diff --git a/apps/coder/src/services/__tests__/provider-commands.test.ts b/apps/coder/src/services/__tests__/provider-commands.test.ts new file mode 100644 index 0000000..c8b07a0 --- /dev/null +++ b/apps/coder/src/services/__tests__/provider-commands.test.ts @@ -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' }, + ]); + }); +}); diff --git a/apps/coder/src/services/__tests__/provider-snapshot.test.ts b/apps/coder/src/services/__tests__/provider-snapshot.test.ts new file mode 100644 index 0000000..dded8cf --- /dev/null +++ b/apps/coder/src/services/__tests__/provider-snapshot.test.ts @@ -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); + }); +}); diff --git a/apps/coder/src/services/acp-client-fs.ts b/apps/coder/src/services/acp-client-fs.ts new file mode 100644 index 0000000..87d6061 --- /dev/null +++ b/apps/coder/src/services/acp-client-fs.ts @@ -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 { + 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 { + 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'); +} diff --git a/apps/coder/src/services/acp-derive.ts b/apps/coder/src/services/acp-derive.ts new file mode 100644 index 0000000..9a8d538 --- /dev/null +++ b/apps/coder/src/services/acp-derive.ts @@ -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; + +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; +} diff --git a/apps/coder/src/services/acp-dispatch.ts b/apps/coder/src/services/acp-dispatch.ts index a4497f3..de450fa 100644 --- a/apps/coder/src/services/acp-dispatch.ts +++ b/apps/coder/src/services/acp-dispatch.ts @@ -1,22 +1,12 @@ /** - * ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host. + * ACP dispatch — runs ACP-capable agents directly on the host. * - * v2.1.1: BooCoder runs on the host now — agents are spawned directly, - * no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC. - * - * Flow: - * 1. Spawn `opencode acp` (or `goose acp`) in the worktree - * 2. Wrap child's stdin/stdout into NDJSON streams - * 3. Create a ClientSideConnection from the SDK - * 4. Initialize → newSession → prompt(task) - * 5. Collect session updates (tool calls, text output) - * 6. On prompt completion → return collected output + * 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 { Readable, Writable } from 'node:stream'; import { ClientSideConnection, - ndJsonStream, type Client, type SessionNotification, type RequestPermissionRequest, @@ -27,13 +17,30 @@ import { 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; - toolCalls: Array<{ title: string; input: unknown; output?: unknown }>; + toolSnapshots: AcpToolSnapshot[]; + reasoningText: string; stopReason: string; } @@ -42,212 +49,316 @@ export interface AcpDispatchOpts { 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; } -function acpArgs(agent: string): string[] | null { - switch (agent) { - case 'opencode': - return ['acp']; - case 'goose': - return ['acp']; - default: - return null; +async function applySessionOverrides( + connection: ConnectionType, + acpSessionId: string, + configOptions: SessionConfigOption[] | null | undefined, + opts: Pick, +): Promise { + 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', + ); + } + } } } -/** - * Convert a Node.js Readable stream to a web ReadableStream. - */ -function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream { - return new ReadableStream({ - 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(); +class AcpStreamContext { + readonly textChunks: string[] = []; + readonly reasoningChunks: string[] = []; + readonly toolSnapshots = new Map(); + 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[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 { + 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 => { + 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 => { + const content = await readWorktreeTextFile( + this.worktreePath, + params.path, + params.line, + params.limit, + ); + return { content }; + }, + writeTextFile: async (params: WriteTextFileRequest): Promise => { + await writeWorktreeTextFile(this.worktreePath, params.path, params.content); + return {}; + }, + createTerminal: async (_params: CreateTerminalRequest): Promise => { + return { terminalId: 'noop' }; + }, + }; + } } -/** - * Convert a Node.js Writable stream to a web WritableStream. - */ -function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream { - return new WritableStream({ - write(chunk) { - return new Promise((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((resolve) => { - (nodeStream as Writable).end(resolve); - }); - }, - abort() { - (nodeStream as Writable).destroy(); - }, - }); -} - -/** - * Dispatch a task to an ACP-capable agent via SSH. - * - * Opens a structured ACP session, sends the task as a prompt, and collects - * all session updates. Returns the collected output and tool calls. - */ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise { - const { agent, task, worktreePath, installPath, signal, log } = opts; + const { + agent, + task, + worktreePath, + installPath, + signal, + log, + taskId, + modeId, + sessionId, + chatId, + messageId, + broker, + } = opts; - const args = acpArgs(agent); + const args = resolveAcpSpawnArgs(agent); if (!args) { return { exitCode: 1, output: `Agent '${agent}' does not support ACP.`, - toolCalls: [], + toolSnapshots: [], + reasoningText: '', stopReason: 'error', }; } const binary = installPath ?? agent; - log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning'); + log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning'); const child = spawn(binary, args, { cwd: worktreePath, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, }); - // Wire up abort + 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', toolCalls: [], stopReason: 'cancelled' }; + return { + exitCode: 130, + output: 'Aborted before start', + toolSnapshots: streamCtx.snapshots, + reasoningText: '', + stopReason: 'cancelled', + }; } signal.addEventListener('abort', cleanup, { once: true }); } try { - // Create web streams from the child process stdio - const inputStream = nodeReadableToWeb(child.stdout!); - const outputStream = nodeWritableToWeb(child.stdin!); - - // Create the NDJSON ACP stream - const stream = ndJsonStream(outputStream, inputStream); - - // Collected session updates - const textChunks: string[] = []; - const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = []; - - // Create client-side connection — we are the "client" (editor), the agent is remote + const stream = createAcpNdJsonStream(child); const connection = new ClientSideConnection( - (_agentInterface): Client => ({ - // Handle session updates from the agent - async sessionUpdate(params: SessionNotification): Promise { - const update = params.update; - if (update.sessionUpdate === 'agent_message_chunk') { - // ContentChunk with content: ContentBlock - const content = update.content; - if (content.type === 'text' && 'text' in content) { - textChunks.push((content as { text: string }).text); - } - } else if (update.sessionUpdate === 'tool_call') { - toolCalls.push({ - title: update.title, - input: update.rawInput, - }); - } else if (update.sessionUpdate === 'tool_call_update') { - const last = toolCalls[toolCalls.length - 1]; - if (last && update.rawOutput !== undefined) { - last.output = update.rawOutput; - } - } - }, - - // Permission requests — auto-approve by selecting the first option (worktree is isolated) - async requestPermission(params: RequestPermissionRequest): Promise { - // Select the first available option to auto-approve - const firstOption = params.options[0]; - if (firstOption) { - return { - outcome: { outcome: 'selected', optionId: firstOption.optionId }, - }; - } - // No options available — cancel - return { outcome: { outcome: 'cancelled' } }; - }, - - // File system operations — let the agent handle them directly in the worktree - async readTextFile(_params: ReadTextFileRequest): Promise { - return { content: '' }; - }, - async writeTextFile(_params: WriteTextFileRequest): Promise { - return {}; - }, - async createTerminal(_params: CreateTerminalRequest): Promise { - return { terminalId: 'noop' }; - }, - }), + () => streamCtx.buildClient(agent, modeId, taskId, sessionId), stream, ); - // Initialize the connection - // ProtocolVersion is a number in this SDK version - const initResult = await connection.initialize({ + await connection.initialize({ protocolVersion: 1, - clientInfo: { name: 'boocoder', version: '2.0.1' }, + clientInfo: { name: 'boocoder', version: '2.3.0' }, clientCapabilities: {}, }); - log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized'); - // Create a new session - const session = await connection.newSession({ - cwd: worktreePath, - mcpServers: [], - }); - log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created'); + 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); - // Send the prompt const promptResult = await connection.prompt({ - sessionId: session.sessionId, + sessionId: acpSession.sessionId, prompt: [{ type: 'text', text: task }], }); const stopReason = promptResult.stopReason ?? 'end_turn'; - log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed'); + log.info( + { agent, stopReason, toolCallCount: streamCtx.snapshots.length, reasoningChars: streamCtx.reasoningText.length }, + 'acp-dispatch: prompt completed', + ); - // Clean shutdown - await connection.closeSession({ sessionId: session.sessionId }).catch(() => {}); + await connection.closeSession({ sessionId: acpSession.sessionId }).catch(() => {}); return { exitCode: 0, - output: textChunks.join(''), - toolCalls, + output: streamCtx.output, + toolSnapshots: streamCtx.snapshots, + reasoningText: streamCtx.reasoningText, stopReason, }; } catch (err) { @@ -256,14 +367,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise((resolve) => { child.on('close', resolve); setTimeout(resolve, 3_000); diff --git a/apps/coder/src/services/acp-probe.ts b/apps/coder/src/services/acp-probe.ts new file mode 100644 index 0000000..fff9c64 --- /dev/null +++ b/apps/coder/src/services/acp-probe.ts @@ -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 { + 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 { + const first = params.options[0]; + if (first) { + return { outcome: { outcome: 'selected', optionId: first.optionId } }; + } + return { outcome: { outcome: 'cancelled' } }; + }, + async readTextFile(_params: ReadTextFileRequest): Promise { + return { content: '' }; + }, + async writeTextFile(_params: WriteTextFileRequest): Promise { + return {}; + }, + async createTerminal(_params: CreateTerminalRequest): Promise { + 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((resolve) => { + child.on('close', resolve); + setTimeout(resolve, 2_000); + }); + } +} diff --git a/apps/coder/src/services/acp-spawn.ts b/apps/coder/src/services/acp-spawn.ts new file mode 100644 index 0000000..9ef3a2f --- /dev/null +++ b/apps/coder/src/services/acp-spawn.ts @@ -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]; + } +} diff --git a/apps/coder/src/services/acp-stream.ts b/apps/coder/src/services/acp-stream.ts new file mode 100644 index 0000000..74c9c9b --- /dev/null +++ b/apps/coder/src/services/acp-stream.ts @@ -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 { + return new ReadableStream({ + 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 { + return new WritableStream({ + write(chunk) { + return new Promise((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((resolve) => { + (nodeStream as Writable).end(resolve); + }); + }, + abort() { + (nodeStream as Writable).destroy(); + }, + }); +} + +export function createAcpNdJsonStream(child: ChildProcess) { + return ndJsonStream(nodeWritableToWeb(child.stdin!), nodeReadableToWeb(child.stdout!)); +} diff --git a/apps/coder/src/services/acp-tool-snapshot.ts b/apps/coder/src/services/acp-tool-snapshot.ts new file mode 100644 index 0000000..e161ced --- /dev/null +++ b/apps/coder/src/services/acp-tool-snapshot.ts @@ -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(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; + const msg = rec.message ?? rec.error ?? rec.reason; + if (typeof msg === 'string' && msg.trim()) return msg; + } + return undefined; +} + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +export function snapshotToWireToolCall(snapshot: AcpToolSnapshot): { + id: string; + name: string; + args: Record; +} { + 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; +} { + const wire = snapshotToWireToolCall(snapshot); + return { id: wire.id, name: wire.name, args: wire.args }; +} + +export function synthesizeCanceledSnapshots(snapshots: Iterable): 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; +} diff --git a/apps/coder/src/services/agent-commands-cache.ts b/apps/coder/src/services/agent-commands-cache.ts new file mode 100644 index 0000000..02b1710 --- /dev/null +++ b/apps/coder/src/services/agent-commands-cache.ts @@ -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(); + +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); +} diff --git a/apps/coder/src/services/agent-probe.ts b/apps/coder/src/services/agent-probe.ts index ab89b28..041b5d6 100644 --- a/apps/coder/src/services/agent-probe.ts +++ b/apps/coder/src/services/agent-probe.ts @@ -2,77 +2,99 @@ import type { Sql } from '../db.js'; import type { FastifyBaseLogger } from 'fastify'; import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; -import { PROVIDERS_BY_NAME } from './provider-registry.js'; +import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js'; +import { resolveAcpProbeBinaries } from './acp-spawn.js'; +import { clearProviderSnapshotCache } from './provider-snapshot.js'; +import { readQwenSettingsModels } from './qwen-settings.js'; const exec = promisify(execCb); -const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({ - name, - supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp', -})); +async function resolveInstallPath(agentName: string): Promise { + const candidates = resolveAcpProbeBinaries(agentName); + for (const bin of candidates) { + try { + const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 }); + const path = stdout.trim(); + if (path) return path; + } catch { + /* try next */ + } + } + return null; +} + +async function detectAcpSupport(agentName: string, installPath: string): Promise { + 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. - * - * v2.1.1: BooCoder runs on the host now — agents are local binaries, - * no SSH needed. Direct `which` / `exec` calls. */ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise { + clearProviderSnapshotCache(); log.info('agent-probe: scanning for known agents'); - for (const agent of KNOWN_AGENTS) { + for (const agentName of PROBED_AGENT_NAMES) { try { - const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 }); - const installPath = whichOut.trim(); + const installPath = await resolveInstallPath(agentName); if (!installPath) continue; let version: string | null = null; try { - const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 }); + const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 }); version = verOut.trim().slice(0, 100); } catch { - // Some agents may not support --version + /* optional */ } - let supportsAcp = agent.supportsAcp; + const providerDef = PROVIDERS_BY_NAME.get(agentName); + let supportsAcp = providerDef?.transport === 'acp'; if (supportsAcp) { - try { - await exec(`${agent.name} acp --help`, { timeout: 10_000 }); - } catch { - supportsAcp = false; - } + supportsAcp = await detectAcpSupport(agentName, installPath); } let models: Array<{ id: string; label: string }> = []; - const providerDef = PROVIDERS_BY_NAME.get(agent.name); - if (providerDef?.modelSource === 'static' && providerDef.staticModels) { models = providerDef.staticModels; } - if (agent.name === 'qwen') { - try { - const { stdout: catOut } = await exec('cat ~/.qwen/settings.json', { timeout: 10_000 }); - if (catOut.trim()) { - const settings = JSON.parse(catOut) as { - modelProviders?: { openai?: Array<{ id: string }> }; - }; - const openaiModels = settings?.modelProviders?.openai; - if (Array.isArray(openaiModels)) { - models = openaiModels.map((m) => ({ id: m.id, label: m.id })); - } - } - } catch { - // ~/.qwen/settings.json missing or unparseable - } + if (agentName === 'qwen') { + models = await readQwenSettingsModels(); } - const label = providerDef?.label ?? agent.name; - const transport = providerDef?.transport ?? 'pty'; + const label = providerDef?.label ?? agentName; + const transport = + providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty'); await sql` INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport) - VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport}) + VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport}) ON CONFLICT (name) DO UPDATE SET install_path = EXCLUDED.install_path, version = EXCLUDED.version, @@ -82,10 +104,10 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise { + 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 { + 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); +} diff --git a/apps/coder/src/services/cursor-models.ts b/apps/coder/src/services/cursor-models.ts new file mode 100644 index 0000000..ecf9fa0 --- /dev/null +++ b/apps/coder/src/services/cursor-models.ts @@ -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, + })); +} diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index c3bb1c1..1890e72 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -1,10 +1,14 @@ import type { Sql } from '../db.js'; import type { FastifyBaseLogger } from 'fastify'; import type { Broker } from '@boocode/server/broker'; +import type { WsFrame } from '@boocode/server/ws-frames'; import type { Config } from '../config.js'; import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js'; import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaPty } from './pty-dispatch.js'; +import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js'; +import { getManifestCommands } from './provider-commands.js'; +import { persistExternalAgentTurn } from './agent-turn-persist.js'; interface InferenceRunner { enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; @@ -24,7 +28,7 @@ const POLL_INTERVAL_MS = 5_000; const COMPLETION_POLL_MS = 2_000; export function createDispatcher(deps: Deps): { start(): void; stop(): Promise } { - const { sql, inference, log, config } = deps; + const { sql, inference, broker, log, config } = deps; let timer: ReturnType | null = null; let running = false; let stopping = false; @@ -34,8 +38,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise` - SELECT id, project_id, input, agent, model, session_id + const rows = await sql<{ + id: string; + project_id: string; + input: string; + agent: string | null; + model: string | null; + mode_id: string | null; + thinking_option_id: string | null; + session_id: string | null; + }[]>` + SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id FROM tasks WHERE state = 'pending' ORDER BY created_at @@ -51,7 +64,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { + async function runTask(task: { + id: string; + project_id: string; + input: string; + agent: string | null; + model: string | null; + mode_id: string | null; + thinking_option_id: string | null; + session_id: string | null; + }): Promise { const taskId = task.id; // Determine execution path: if agent is specified AND exists in available_agents → Path B @@ -179,7 +201,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { @@ -265,6 +296,33 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise` + 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({ @@ -273,16 +331,20 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { + return new Promise((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 | 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(); + }); +} diff --git a/apps/coder/src/services/mcp-server.ts b/apps/coder/src/services/mcp-server.ts index 47e0bf2..901ab7a 100644 --- a/apps/coder/src/services/mcp-server.ts +++ b/apps/coder/src/services/mcp-server.ts @@ -57,14 +57,29 @@ export async function startMcpServer(sql: Sql): Promise { 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` - INSERT INTO tasks (project_id, input, agent, model, state) - VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending') + 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 }); + return textResult({ + task_id: row!.id, + state: row!.state, + mode_id: args.mode_id ?? null, + thinking_option_id: args.thinking_option_id ?? null, + }); }, ); @@ -147,11 +162,21 @@ export async function startMcpServer(sql: Sql): Promise { 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` - INSERT INTO tasks (project_id, input, agent, model, state) - VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? null}, 'pending') + 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 `; @@ -161,7 +186,13 @@ export async function startMcpServer(sql: Sql): Promise { `; const executionPath = agentRow?.supports_acp ? 'acp' : 'pty'; - return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath }); + return textResult({ + task_id: row!.id, + state: row!.state, + execution_path: executionPath, + mode_id: args.mode_id ?? null, + thinking_option_id: args.thinking_option_id ?? null, + }); }, ); diff --git a/apps/coder/src/services/permission-waiter.ts b/apps/coder/src/services/permission-waiter.ts new file mode 100644 index 0000000..2a9fcf3 --- /dev/null +++ b/apps/coder/src/services/permission-waiter.ts @@ -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; +} + +const pendingByTask = new Map(); + +export interface PermissionPrompt { + taskId: string; + toolTitle?: string; + options: Array<{ optionId: string; label: string }>; +} + +export interface PermissionHooks { + onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise; + onResolved?: (taskId: string, sessionId: string) => void | Promise; +} + +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 { + 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); +} diff --git a/apps/coder/src/services/provider-commands.ts b/apps/coder/src/services/provider-commands.ts new file mode 100644 index 0000000..e9f2a08 --- /dev/null +++ b/apps/coder/src/services/provider-commands.ts @@ -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 = { + 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(); + 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)); +} diff --git a/apps/coder/src/services/provider-manifest.ts b/apps/coder/src/services/provider-manifest.ts new file mode 100644 index 0000000..6972708 --- /dev/null +++ b/apps/coder/src/services/provider-manifest.ts @@ -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 = { + 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); +} diff --git a/apps/coder/src/services/provider-registry.ts b/apps/coder/src/services/provider-registry.ts index 7a58ce9..964c6bc 100644 --- a/apps/coder/src/services/provider-registry.ts +++ b/apps/coder/src/services/provider-registry.ts @@ -2,10 +2,21 @@ export interface ProviderDef { name: string; label: string; transport: 'native' | 'acp' | 'pty'; - modelSource: 'llama-swap' | 'static'; + 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', @@ -13,17 +24,24 @@ export const PROVIDERS: ProviderDef[] = [ transport: 'native', modelSource: 'llama-swap', }, + { + name: 'cursor', + label: 'Cursor Agent', + transport: 'acp', + modelSource: 'probe', + }, { name: 'opencode', label: 'OpenCode', transport: 'acp', - modelSource: 'llama-swap', + modelSource: 'probe', + mergeLlamaSwap: true, }, { name: 'goose', label: 'Goose', transport: 'acp', - modelSource: 'llama-swap', + modelSource: 'probe', }, { name: 'claude', @@ -38,9 +56,18 @@ export const PROVIDERS: ProviderDef[] = [ { name: 'qwen', label: 'Qwen Code', - transport: 'pty', - modelSource: 'static', + 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); diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts new file mode 100644 index 0000000..2643109 --- /dev/null +++ b/apps/coder/src/services/provider-snapshot.ts @@ -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 { + 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 { + 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(); + 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 { + 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(); +const snapshotInflight = new Map>(); +const CACHE_TTL_MS = 5 * 60_000; + +export async function getProviderSnapshot( + sql: Sql, + config: Config, + cwd?: string, + force = false, +): Promise { + 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 => { + const llamaModels = await fetchLlamaSwapModels(config); + const agents = await sql` + 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 { + 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'); + } +} diff --git a/apps/coder/src/services/provider-types.ts b/apps/coder/src/services/provider-types.ts new file mode 100644 index 0000000..87c10c4 --- /dev/null +++ b/apps/coder/src/services/provider-types.ts @@ -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; +} diff --git a/apps/coder/src/services/pty-dispatch.ts b/apps/coder/src/services/pty-dispatch.ts index ff07e0b..2841482 100644 --- a/apps/coder/src/services/pty-dispatch.ts +++ b/apps/coder/src/services/pty-dispatch.ts @@ -1,15 +1,5 @@ /** * PTY dispatch — runs external agents directly on the host. - * - * v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the - * install_path from agent-probe. Follows Paseo's pattern: direct binary - * path + args array + cwd. - * - * Supported agents: - * - claude: `claude -p --model ` (print mode, reads task from stdin) - * - opencode: `opencode --model ` (stdin pipe) - * - qwen: `qwen -p --output-format stream-json` - * - goose: `goose run --text ` */ import type { FastifyBaseLogger } from 'fastify'; import { spawn } from 'node:child_process'; @@ -25,27 +15,44 @@ export interface PtyDispatchOpts { task: string; worktreePath: string; model?: string; + modeId?: string; + thinkingOptionId?: string; installPath?: string; signal?: AbortSignal; log: FastifyBaseLogger; } -interface AgentCommand { +interface PtySpawnSpec { binary: string; args: string[]; stdin?: string; } -function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null { +function buildPtySpawnSpec( + agent: string, + task: string, + model?: string, + modeId?: string, + thinkingOptionId?: string, + installPath?: string, +): PtySpawnSpec | null { const binary = installPath ?? agent; switch (agent) { - case 'claude': - return { - binary, - args: model ? ['-p', '--model', model] : ['-p'], - stdin: task, - }; + 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 { @@ -54,20 +61,10 @@ function buildAgentCommand(agent: string, task: string, model?: string, installP stdin: task, }; - case 'qwen': - return { - binary, - args: model - ? ['-p', task, '--model', model, '--output-format', 'stream-json'] - : ['-p', task, '--output-format', 'stream-json'], - }; - case 'goose': return { binary, - args: model - ? ['run', '--text', task, '--model', model] - : ['run', '--text', task], + args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task], }; default: @@ -76,9 +73,9 @@ function buildAgentCommand(agent: string, task: string, model?: string, installP } export async function dispatchViaPty(opts: PtyDispatchOpts): Promise { - const { agent, task, worktreePath, model, installPath, signal, log } = opts; + const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts; - const cmd = buildAgentCommand(agent, task, model, installPath); + const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath); if (!cmd) { return { exitCode: 1, @@ -87,7 +84,7 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise((resolve, reject) => { const child = spawn(cmd.binary, cmd.args, { diff --git a/apps/coder/src/services/qwen-settings.ts b/apps/coder/src/services/qwen-settings.ts new file mode 100644 index 0000000..e377cb9 --- /dev/null +++ b/apps/coder/src/services/qwen-settings.ts @@ -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 { + 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 []; + } +} diff --git a/apps/coder/src/services/ssh.ts b/apps/coder/src/services/ssh.ts deleted file mode 100644 index d457b35..0000000 --- a/apps/coder/src/services/ssh.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @deprecated v2.1.1 — BooCoder runs on the host now. Use direct spawn/exec instead. - * Kept for one release cycle in case of rollback. - * - * SSH helper — spawns commands on the host via SSH. - * - * BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi). - * They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the - * Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53). - */ -import { spawn, type ChildProcess } from 'node:child_process'; - -export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53'; -export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop'; - -/** Common SSH args — strict host checking disabled for container-to-host trust. */ -const SSH_BASE_ARGS = [ - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - '-o', 'LogLevel=ERROR', - '-o', 'BatchMode=yes', -]; - -export interface SshExecResult { - exitCode: number; - stdout: string; - stderr: string; -} - -/** - * Execute a command on the host via SSH, collecting all output. - * Returns when the remote process exits. - */ -export async function sshExec( - command: string, - opts?: { signal?: AbortSignal; timeoutMs?: number }, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn('ssh', [ - ...SSH_BASE_ARGS, - `${SSH_USER}@${SSH_HOST}`, - command, - ], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - let killed = false; - - child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); - child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); - - const cleanup = () => { - if (!killed) { - killed = true; - child.kill('SIGTERM'); - } - }; - - // Abort signal - if (opts?.signal) { - if (opts.signal.aborted) { - cleanup(); - reject(new Error('SSH exec aborted before start')); - return; - } - opts.signal.addEventListener('abort', cleanup, { once: true }); - } - - // Timeout - let timer: ReturnType | undefined; - if (opts?.timeoutMs) { - timer = setTimeout(() => { - cleanup(); - reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs); - } - - child.on('close', (code) => { - if (timer) clearTimeout(timer); - if (opts?.signal) opts.signal.removeEventListener('abort', cleanup); - resolve({ exitCode: code ?? 1, stdout, stderr }); - }); - - child.on('error', (err) => { - if (timer) clearTimeout(timer); - if (opts?.signal) opts.signal.removeEventListener('abort', cleanup); - reject(err); - }); - - // Close stdin immediately — we're not sending input via sshExec - child.stdin!.end(); - }); -} - -/** - * Spawn an SSH child process with a command on the host. - * Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY). - */ -export function sshSpawn(command: string): ChildProcess { - return spawn('ssh', [ - ...SSH_BASE_ARGS, - `${SSH_USER}@${SSH_HOST}`, - command, - ], { - stdio: ['pipe', 'pipe', 'pipe'], - }); -} - -/** - * Spawn an SSH child process that pipes stdin through. - * Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`). - */ -export function sshSpawnWithStdin(command: string, input: string): ChildProcess { - const child = spawn('ssh', [ - ...SSH_BASE_ARGS, - `${SSH_USER}@${SSH_HOST}`, - command, - ], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - // Write the input and close stdin - child.stdin!.write(input); - child.stdin!.end(); - - return child; -} diff --git a/apps/coder/src/services/worktrees.ts b/apps/coder/src/services/worktrees.ts index 6ff602e..f62ea5f 100644 --- a/apps/coder/src/services/worktrees.ts +++ b/apps/coder/src/services/worktrees.ts @@ -6,7 +6,7 @@ * After the agent completes, we diff the worktree against HEAD and * queue the diff into pending_changes. */ -import { sshExec } from './ssh.js'; +import { hostExec } from './host-exec.js'; const WORKTREE_BASE = '/tmp/booworktrees'; @@ -23,10 +23,10 @@ export async function createWorktree( const branchName = `task-${taskId}`; // Ensure the base directory exists - await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal }); + await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal }); // Create the worktree with a new branch from HEAD - const result = await sshExec( + const result = await hostExec( `git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`, { signal: opts?.signal, timeoutMs: 30_000 }, ); @@ -49,7 +49,7 @@ export async function diffWorktree( ): Promise { // First, commit any uncommitted changes in the worktree so we can diff branches // Stage all changes - const addResult = await sshExec( + const addResult = await hostExec( `cd ${shellEscape(worktreePath)} && git add -A`, { signal: opts?.signal, timeoutMs: 30_000 }, ); @@ -58,7 +58,7 @@ export async function diffWorktree( } // Check if there are staged changes - const statusResult = await sshExec( + const statusResult = await hostExec( `cd ${shellEscape(worktreePath)} && git diff --cached --quiet`, { signal: opts?.signal, timeoutMs: 10_000 }, ); @@ -69,13 +69,13 @@ export async function diffWorktree( } // Commit staged changes (needed to produce a clean branch diff) - await sshExec( + await hostExec( `cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`, { signal: opts?.signal, timeoutMs: 15_000 }, ); // Diff the worktree branch against the parent commit (HEAD of main tree) - const diffResult = await sshExec( + const diffResult = await hostExec( `git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`, { signal: opts?.signal, timeoutMs: 60_000 }, ); @@ -99,13 +99,13 @@ export async function cleanupWorktree( const branchName = `task-${taskId}`; // Remove the worktree (--force handles dirty state) - await sshExec( + await hostExec( `git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`, { timeoutMs: 15_000 }, ).catch(() => {}); // Delete the task branch - await sshExec( + await hostExec( `git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`, { timeoutMs: 10_000 }, ).catch(() => {}); diff --git a/apps/server/package.json b/apps/server/package.json index bae1932..319536e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,7 +19,9 @@ "./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" }, "./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" }, "./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" }, - "./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" } + "./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }, + "./skills": { "types": "./dist/services/skills.d.ts", "default": "./dist/services/skills.js" }, + "./skill-invoke": { "types": "./dist/services/skill-invoke.d.ts", "default": "./dist/services/skill-invoke.js" } }, "scripts": { "dev": "tsx watch src/index.ts", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2d8ceaf..35a31d0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -14,6 +14,7 @@ import { registerArtifactRoutes } from './routes/artifacts.js'; import { registerChatRoutes } from './routes/chats.js'; import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerWebSocket } from './routes/ws.js'; +import { registerCoderProxy } from './routes/coder-proxy.js'; import { registerModelRoutes } from './routes/models.js'; import { registerAgentRoutes } from './routes/agents.js'; import { registerSkillsRoutes } from './routes/skills.js'; @@ -212,36 +213,10 @@ async function main() { }); registerWebSocket(app, sql, broker); - // v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the - // SPA's HTTP requests going through a single origin (avoids CORS). WS for - // the coder pane connects directly to boocoder:9502 from the browser (same - // Tailscale network — no CORS issue for WebSocket upgrade requests). + // v2.0.0: reverse proxy /api/coder/* to boocoder (HTTP + WS). CoderPane + // connects WS through /api/coder/ws/sessions/:id on the same origin. const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000'; - app.all('/api/coder/*', async (req, reply) => { - const targetPath = req.url.replace('/api/coder', '/api'); - const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`; - const headers: Record = {}; - if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string; - if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string; - - try { - const res = await fetch(targetUrl, { - method: req.method as string, - headers, - body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, - }); - reply.code(res.status); - for (const [key, value] of res.headers) { - if (key === 'transfer-encoding') continue; - reply.header(key, value); - } - const body = await res.text(); - return reply.send(body); - } catch (err) { - app.log.error({ err, targetUrl }, 'coder proxy error'); - reply.code(502).send({ error: 'boocoder backend unavailable' }); - } - }); + registerCoderProxy(app, BOOCODER_ORIGIN); const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); if (existsSync(webDist)) { diff --git a/apps/server/src/routes/coder-proxy.ts b/apps/server/src/routes/coder-proxy.ts new file mode 100644 index 0000000..eeeedc7 --- /dev/null +++ b/apps/server/src/routes/coder-proxy.ts @@ -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 = {}; + 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' }); + } + }); +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 0a50854..371d630 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -33,7 +33,8 @@ const WorkspacePaneZ = z.object({ kind: z.enum([ 'chat', 'terminal', - 'agent', + 'coder', + 'agent', // legacy alias — normalized to coder on write 'empty', 'settings', 'markdown_artifact', @@ -307,9 +308,12 @@ export function registerSessionRoutes( reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } + const workspacePanes = parsed.data.workspace_panes.map((pane) => + pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane, + ); const rows = await sql` UPDATE sessions - SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)}, + SET workspace_panes = ${sql.json(workspacePanes as never)}, updated_at = clock_timestamp() WHERE id = ${req.params.id} RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, diff --git a/apps/server/src/routes/skills.ts b/apps/server/src/routes/skills.ts index f8965c6..d5632ab 100644 --- a/apps/server/src/routes/skills.ts +++ b/apps/server/src/routes/skills.ts @@ -1,9 +1,13 @@ -import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Chat } from '../types/api.js'; import { getSkillBody, listSkills } from '../services/skills.js'; +import { + buildSkillInvokeSyntheticFrames, + DEFAULT_SKILL_USER_MESSAGE, + runSkillInvokeTransaction, +} from '../services/skill-invoke.js'; // Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in // routes/messages.ts so index.ts can pass thin adapters around broker + @@ -35,8 +39,6 @@ const SkillInvokeBody = z.object({ user_message: z.string().max(64_000).nullable().optional(), }); -const DEFAULT_USER_MESSAGE = 'Apply this skill.'; - export function registerSkillsRoutes( app: FastifyInstance, sql: Sql, @@ -62,7 +64,9 @@ export function registerSkillsRoutes( return { error: 'invalid body', details: parsed.error.flatten() }; } const { skill_name } = parsed.data; - const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE; + const userText = parsed.data.user_message?.trim() + ? parsed.data.user_message + : DEFAULT_SKILL_USER_MESSAGE; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' @@ -80,87 +84,20 @@ export function registerSkillsRoutes( return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; } - const toolCallId = randomUUID(); - const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }]; - const toolResults = { tool_call_id: toolCallId, output: body, truncated: false }; - - const result = await sql.begin(async (tx) => { - const [synthAssistant] = await tx<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp()) - RETURNING id - `; - // v1.13.20: parts-only write. Single skill_use tool_call, no text - // content, so one part at seq 0. - await tx` - INSERT INTO message_parts (message_id, sequence, kind, payload) - VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({ - id: toolCallId, - name: 'skill_use', - args: { name: skill_name }, - } as never)}) - `; - const [toolMsg] = await tx<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp()) - RETURNING id - `; - // v1.13.20: parts-only write of the synthetic tool result (skill body). - await tx` - INSERT INTO message_parts (message_id, sequence, kind, payload) - VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)}) - `; - const [userMsg] = await tx<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp()) - RETURNING id - `; - const [assistantMsg] = await tx<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) - RETURNING id - `; - await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; - await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; - return { - synth_assistant_id: synthAssistant!.id, - tool_message_id: toolMsg!.id, - user_message_id: userMsg!.id, - assistant_message_id: assistantMsg!.id, - }; + const { result, toolCall } = await runSkillInvokeTransaction(sql, { + sessionId, + chatId: chat.id, + skillName: skill_name, + skillBody: body, + userText, }); // Synthetic frames so useSessionStream's reducer reflects the new // history without a refetch. Frame shapes match the streaming-inference // protocol (see services/inference.ts InferenceFrame). - handlers.publishSessionFrame(sessionId, { - type: 'message_started', - message_id: result.synth_assistant_id, - chat_id: chat.id, - role: 'assistant', - }); - handlers.publishSessionFrame(sessionId, { - type: 'tool_call', - message_id: result.synth_assistant_id, - chat_id: chat.id, - tool_call: toolCalls[0]!, - }); - handlers.publishSessionFrame(sessionId, { - type: 'message_complete', - message_id: result.synth_assistant_id, - chat_id: chat.id, - }); - // The tool_result frame's reducer branch creates the tool-role message - // in-place when it doesn't already exist — no separate message_started - // is needed for the tool side. - handlers.publishSessionFrame(sessionId, { - type: 'tool_result', - tool_message_id: result.tool_message_id, - tool_call_id: toolCallId, - chat_id: chat.id, - output: body, - truncated: false, - }); + for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) { + handlers.publishSessionFrame(sessionId, frame); + } handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); diff --git a/apps/server/src/services/__tests__/agents.test.ts b/apps/server/src/services/__tests__/agents.test.ts new file mode 100644 index 0000000..32aaac3 --- /dev/null +++ b/apps/server/src/services/__tests__/agents.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/services/__tests__/inference.test.ts b/apps/server/src/services/__tests__/inference.test.ts index 10bde23..31e2840 100644 --- a/apps/server/src/services/__tests__/inference.test.ts +++ b/apps/server/src/services/__tests__/inference.test.ts @@ -226,6 +226,76 @@ describe('buildMessagesPayload', async () => { expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' }); }); + it('strips assistant tool_calls when matching tool results are missing', async () => { + const session = makeSession(); + const project = makeProject(); + const toolCall: ToolCall = { + id: 'call_orphan', + name: 'grep', + args: { pattern: 'foo' }, + }; + const history: Message[] = [ + makeMessage('user', 'search'), + makeMessage('assistant', 'partial answer', { tool_calls: [toolCall] }), + makeMessage('assistant', 'final answer'), + ]; + const result = await buildMessagesPayload(session, project, history); + // tool_calls stripped from the orphan turn; text content kept. + expect(result).toHaveLength(4); + expect(result[1]).toMatchObject({ role: 'user', content: 'search' }); + expect(result[2]).toMatchObject({ role: 'assistant', content: 'partial answer' }); + expect(result[2]!.tool_calls).toBeUndefined(); + expect(result[3]).toMatchObject({ role: 'assistant', content: 'final answer' }); + }); + + it('drops tool-call-only assistant rows when tool results never arrived', async () => { + const session = makeSession(); + const project = makeProject(); + const toolCall: ToolCall = { + id: 'call_orphan_only', + name: 'grep', + args: { pattern: 'foo' }, + }; + const history: Message[] = [ + makeMessage('user', 'search'), + makeMessage('assistant', '', { tool_calls: [toolCall] }), + makeMessage('assistant', 'final answer'), + ]; + const result = await buildMessagesPayload(session, project, history); + expect(result).toHaveLength(3); + expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' }); + }); + + it('skips stray tool rows when the owning assistant tool_calls were stripped', async () => { + const session = makeSession(); + const project = makeProject(); + const toolCallA: ToolCall = { + id: 'call_a', + name: 'grep', + args: { pattern: 'foo' }, + }; + const toolCallB: ToolCall = { + id: 'call_b', + name: 'read', + args: { path: 'x' }, + }; + const toolResult: ToolResult = { + tool_call_id: 'call_a', + output: 'match', + truncated: false, + }; + const history: Message[] = [ + makeMessage('user', 'search'), + makeMessage('assistant', '', { tool_calls: [toolCallA, toolCallB] }), + makeMessage('tool', '', { tool_results: toolResult }), + makeMessage('assistant', 'final answer'), + ]; + const result = await buildMessagesPayload(session, project, history); + expect(result).toHaveLength(3); + expect(result.find((m) => m.role === 'tool')).toBeUndefined(); + expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' }); + }); + it('skips tool rows with no tool_results', async () => { const session = makeSession(); const project = makeProject(); diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts index 3ad7b58..2729a5b 100644 --- a/apps/server/src/services/agents.ts +++ b/apps/server/src/services/agents.ts @@ -309,6 +309,14 @@ export function parseAgentsMd(content: string): ParseResult { return { agents, errors }; } +/** True when a file at `/AGENTS.md` is an agent registry, not Cursor/doc nav. */ +export function isAgentRegistryMarkdown(content: string): boolean { + const firstLine = content.trimStart().split('\n')[0]?.trim() ?? ''; + // BooCode monorepo root AGENTS.md is navigation only; registry is /data/AGENTS.md. + if (firstLine === '# Agent navigation') return false; + return true; +} + // ---- mtime-keyed cache + public API ---------------------------------------- interface CacheEntry { @@ -397,7 +405,7 @@ export async function getAgentsForProject(projectPath: string): Promise { + const ids = new Set(); + for (let j = assistantIdx + 1; j < history.length; j++) { + const row = history[j]!; + if (row.role === 'user' || row.role === 'assistant') break; + if (row.role === 'tool' && row.tool_results?.tool_call_id) { + ids.add(row.tool_results.tool_call_id); + } + } + return ids; +} + +function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null { + for (let k = toolIdx - 1; k >= 0; k--) { + const row = history[k]!; + if (row.role === 'user') break; + if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k; + } + return null; +} + +function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean { + const assistant = history[assistantIdx]!; + if (!assistant.tool_calls?.length) return false; + const fulfilled = toolResultIdsFollowing(history, assistantIdx); + return assistant.tool_calls.every((tc) => fulfilled.has(tc.id)); +} + export async function buildMessagesPayload( session: Session, project: Project, @@ -97,6 +125,10 @@ export async function buildMessagesPayload( if (m.role === 'tool') { const tr = m.tool_results; if (!tr) continue; + const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id); + if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) { + continue; + } const outputText = tr.error ? `error: ${tr.error}` : typeof tr.output === 'string' @@ -115,11 +147,15 @@ export async function buildMessagesPayload( content: m.content && m.content.length > 0 ? m.content : null, }; if (m.tool_calls && m.tool_calls.length > 0) { - msg.tool_calls = m.tool_calls.map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { name: tc.name, arguments: JSON.stringify(tc.args) }, - })); + if (assistantToolCallsArePayloadComplete(history, i)) { + msg.tool_calls = m.tool_calls.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })); + } + // Orphaned tool_calls (no matching tool rows) are stripped so the + // upstream API does not reject the payload on the next user turn. } // v1.13.1-C: collapse reasoning_parts into a single string. The view // returns them ordered by sequence; multiple reasoning parts on one @@ -127,6 +163,11 @@ export async function buildMessagesPayload( if (m.reasoning_parts && m.reasoning_parts.length > 0) { msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join(''); } + const hasPayload = + (msg.content != null && msg.content.trim().length > 0) || + (msg.tool_calls != null && msg.tool_calls.length > 0) || + (msg.reasoning != null && msg.reasoning.length > 0); + if (!hasPayload) continue; out.push(msg); continue; } diff --git a/apps/server/src/services/skill-invoke.ts b/apps/server/src/services/skill-invoke.ts new file mode 100644 index 0000000..4e7c75e --- /dev/null +++ b/apps/server/src/services/skill-invoke.ts @@ -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 & { 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, + }, + ]; +} diff --git a/apps/server/src/services/skills.ts b/apps/server/src/services/skills.ts index 158485f..d48eb8e 100644 --- a/apps/server/src/services/skills.ts +++ b/apps/server/src/services/skills.ts @@ -16,7 +16,7 @@ import { pathGuard, PathScopeError } from './path_guard.js'; // new skills, per-entry mtime check on body access so a hot-edited SKILL.md // is re-read without a restart. No watcher. -const SKILLS_ROOT = '/data/skills'; +const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills'; const MAX_RESOURCE_BYTES = 5 * 1024 * 1024; const LIST_CACHE_TTL_MS = 60_000; diff --git a/apps/server/src/types/ws-frames.ts b/apps/server/src/types/ws-frames.ts index 5848900..ad38ddf 100644 --- a/apps/server/src/types/ws-frames.ts +++ b/apps/server/src/types/ws-frames.ts @@ -85,6 +85,13 @@ export const DeltaFrame = z.object({ content: z.string(), }); +export const ReasoningDeltaFrame = z.object({ + type: z.literal('reasoning_delta'), + message_id: Uuid, + chat_id: Uuid.optional(), + content: z.string(), +}); + export const ToolCallFrame = z.object({ type: z.literal('tool_call'), message_id: Uuid, @@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({ project_id: Uuid, }); +const PermissionOptionShape = z.object({ + option_id: z.string(), + label: z.string(), +}); + +export const PermissionRequestedFrame = z.object({ + type: z.literal('permission_requested'), + task_id: Uuid, + session_id: Uuid, + tool_title: z.string().optional(), + options: z.array(PermissionOptionShape), +}); + +export const PermissionResolvedFrame = z.object({ + type: z.literal('permission_resolved'), + task_id: Uuid, + session_id: Uuid, +}); + +const AgentCommandShape = z.object({ + name: z.string(), + description: z.string().optional(), +}); + +export const AgentCommandsFrame = z.object({ + type: z.literal('agent_commands'), + task_id: Uuid, + session_id: Uuid, + commands: z.array(AgentCommandShape), +}); + // ---- discriminated union --------------------------------------------------- export const WsFrameSchema = z.discriminatedUnion('type', [ @@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [ SnapshotFrame, MessageStartedFrame, DeltaFrame, + ReasoningDeltaFrame, ToolCallFrame, ToolResultFrame, MessageCompleteFrame, @@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [ ChatRenamedFrame, CompactedFrame, ErrorFrame, + PermissionRequestedFrame, + PermissionResolvedFrame, + AgentCommandsFrame, // per-user ChatStatusFrame, SessionUpdatedFrame, @@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'snapshot', 'message_started', 'delta', + 'reasoning_delta', 'tool_call', 'tool_result', 'message_complete', @@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'chat_renamed', 'compacted', 'error', + 'permission_requested', + 'permission_resolved', + 'agent_commands', 'chat_status', 'session_updated', 'session_renamed', diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1ead2df..6e0a547 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -13,7 +13,13 @@ import type { Skill, AskUserAnswer, ToolCostStat, - Provider, + ProviderSnapshotEntry, + CoderSendMessageBody, + CoderSendMessageResponse, + CoderMessageWire, + CoderTaskDetail, + PermissionPrompt, + AgentCommand, } from './types'; export class ApiError extends Error { @@ -300,7 +306,46 @@ export const api = { models: () => request('/api/models'), coder: { - providers: () => request('/api/coder/providers'), + snapshot: (cwd?: string) => { + const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''; + return request(`/api/coder/providers/snapshot${qs}`); + }, + refreshProviders: () => + request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }), + sendMessage: (sessionId: string, body: CoderSendMessageBody) => + request(`/api/coder/sessions/${sessionId}/messages`, { + method: 'POST', + body: JSON.stringify(body), + }), + getTaskPermission: (taskId: string) => + request(`/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(`/api/coder/tasks/${taskId}`), + listMessages: (sessionId: string, chatId?: string) => + request( + `/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: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index eee145e..1a72c7d 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -209,14 +209,95 @@ export interface ModelInfo { export interface ProviderModel { id: string; label: string; + description?: string; + isDefault?: boolean; + thinkingOptions?: ThinkingOption[]; + defaultThinkingOptionId?: string; } -export interface Provider { +export interface ProviderMode { + id: string; + label: string; + description?: string; + isUnattended?: boolean; +} + +export interface ThinkingOption { + id: string; + label: string; + isDefault?: boolean; +} + +export type ProviderSnapshotStatus = 'ready' | 'error'; + +export interface ProviderSnapshotEntry { name: string; 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 { diff --git a/apps/web/src/api/ws-frames.ts b/apps/web/src/api/ws-frames.ts index 5848900..ad38ddf 100644 --- a/apps/web/src/api/ws-frames.ts +++ b/apps/web/src/api/ws-frames.ts @@ -85,6 +85,13 @@ export const DeltaFrame = z.object({ content: z.string(), }); +export const ReasoningDeltaFrame = z.object({ + type: z.literal('reasoning_delta'), + message_id: Uuid, + chat_id: Uuid.optional(), + content: z.string(), +}); + export const ToolCallFrame = z.object({ type: z.literal('tool_call'), message_id: Uuid, @@ -256,6 +263,37 @@ export const ProjectDeletedFrame = z.object({ project_id: Uuid, }); +const PermissionOptionShape = z.object({ + option_id: z.string(), + label: z.string(), +}); + +export const PermissionRequestedFrame = z.object({ + type: z.literal('permission_requested'), + task_id: Uuid, + session_id: Uuid, + tool_title: z.string().optional(), + options: z.array(PermissionOptionShape), +}); + +export const PermissionResolvedFrame = z.object({ + type: z.literal('permission_resolved'), + task_id: Uuid, + session_id: Uuid, +}); + +const AgentCommandShape = z.object({ + name: z.string(), + description: z.string().optional(), +}); + +export const AgentCommandsFrame = z.object({ + type: z.literal('agent_commands'), + task_id: Uuid, + session_id: Uuid, + commands: z.array(AgentCommandShape), +}); + // ---- discriminated union --------------------------------------------------- export const WsFrameSchema = z.discriminatedUnion('type', [ @@ -263,6 +301,7 @@ export const WsFrameSchema = z.discriminatedUnion('type', [ SnapshotFrame, MessageStartedFrame, DeltaFrame, + ReasoningDeltaFrame, ToolCallFrame, ToolResultFrame, MessageCompleteFrame, @@ -271,6 +310,9 @@ export const WsFrameSchema = z.discriminatedUnion('type', [ ChatRenamedFrame, CompactedFrame, ErrorFrame, + PermissionRequestedFrame, + PermissionResolvedFrame, + AgentCommandsFrame, // per-user ChatStatusFrame, SessionUpdatedFrame, @@ -300,6 +342,7 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'snapshot', 'message_started', 'delta', + 'reasoning_delta', 'tool_call', 'tool_result', 'message_complete', @@ -308,6 +351,9 @@ export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'chat_renamed', 'compacted', 'error', + 'permission_requested', + 'permission_resolved', + 'agent_commands', 'chat_status', 'session_updated', 'session_renamed', diff --git a/apps/web/src/components/AgentCommandsHint.tsx b/apps/web/src/components/AgentCommandsHint.tsx new file mode 100644 index 0000000..64fc741 --- /dev/null +++ b/apps/web/src/components/AgentCommandsHint.tsx @@ -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 ( +
+ + {open && ( +
    + {commands.map((cmd) => ( +
  • + /{cmd.name} + {cmd.description && ( + {cmd.description} + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx new file mode 100644 index 0000000..398af5a --- /dev/null +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -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; + +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 = ( +
+ {options.map((o) => ( + + ))} +
+ ); + + if (isMobile) { + return ( + <> + + setOpen(false)} title={label}> +
{list}
+
+ + ); + } + + return ( + + + + + + {options.map((o) => ( + onPick(o.id)} className="font-mono text-xs"> + + {o.label} + + ))} + + + ); +} + +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 { + setRefreshing(true); + try { + await api.coder.refreshProviders(); + await refreshProviderSnapshot(projectPath); + } finally { + setRefreshing(false); + } + } + + if (!entries) { + return ( +
Loading agents…
+ ); + } + + 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 ( +
+ } + /> + persist({ ...value, modeId })} + icon={} + /> + + {thinkingOpts.length > 0 && ( + persist({ ...value, thinkingOptionId })} + icon={} + /> + )} + +
+ ); +} diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 948c2bc..e2c533d 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -23,7 +23,8 @@ import { FileMentionPopover } from '@/components/FileMentionPopover'; import { DropOverlay } from '@/components/DropOverlay'; import { AgentPicker } from '@/components/AgentPicker'; import { ContextBar } from '@/components/ContextBar'; -import { SkillSlashCommand } from '@/components/SkillSlashCommand'; +import { SlashCommandPicker } from '@/components/SlashCommandPicker'; +import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { api } from '@/api/client'; import type { Message } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; @@ -87,10 +88,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session // Batch 9.6: slash-command dropdown. Opens when `/` is the first char of // the input and stays open while the input is `/` with no whitespace. // Disabled entirely when the caller doesn't pass onSlashCommand. - // v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand - // now reads the live textarea rect via inputRef (textareaRef below) so it can - // recompute on visualViewport changes (iOS keyboard open/close), so the - // anchorRect field is no longer needed in this state. + // SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below) + // so it can recompute on visualViewport changes (iOS keyboard open/close). const [slashState, setSlashState] = useState<{ query: string; } | null>(null); @@ -168,13 +167,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session // input parses to a known skill. Falls through to onSend for unknown // slash names (literal text) or when slash dispatch isn't wired. if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) { - const match = text.match(/^\/(\S+)\s*([\s\S]*)$/); - if (match && skillsLookup.has(match[1]!)) { - const skillName = match[1]!; - const args = (match[2] ?? '').trim(); + const parsed = parseSlashInput(text); + if (parsed && skillsLookup.has(parsed.cmdName)) { setBusy(true); try { - await onSlashCommand(skillName, args); + await onSlashCommand(parsed.cmdName, parsed.args); setValue(''); setAttachments([]); setSlashState(null); @@ -268,8 +265,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session // slash-prefixed token with no whitespace (i.e. user is still typing the // skill name). Hand off to args mode the moment a space appears or the // slash leaves position 0. - if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) { - const query = newValue.slice(1); + if (onSlashCommand && isSlashCommandToken(newValue)) { + const query = slashQuery(newValue); if (!slashState) { setSlashState({ query }); } else if (slashState.query !== query) { @@ -496,7 +493,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session function onKeyDown(e: KeyboardEvent) { if (mentionState?.open) return; - // SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let + // SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let // it consume them so the textarea doesn't also submit on Enter. if (slashState) return; // IME safety: never act on Enter while an IME composition is in flight @@ -658,12 +655,13 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session /> )} {slashState && ( - setSlashState(null)} + emptyLabel="No skills available" /> )} diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index 28a7358..5c57e98 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -183,13 +183,13 @@ export function ChatTabBar({ onAddPane('chat')}> - New chat + New BooChat onAddPane('terminal')}> - New terminal + New BooTerm onAddPane('coder')}> - New coder + New BooCode diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index a1715cb..cb2893f 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -14,6 +14,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, @@ -37,14 +38,15 @@ function useTerminals(): TerminalRegistration[] { return list; } -// Wrap a message body with a right-click context menu offering "Send to -// terminal → ". The submenu is disabled when nothing is selected -// or no terminal panes are open; clicking a target emits a sendToTerminal -// event that TerminalPane subscribes to (filtered by pane_id). +// Wrap a message body with a right-click context menu offering Copy and +// "Send to terminal → ". Send is disabled when nothing is +// selected or no terminal panes are open; clicking a target emits a +// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id). function SendToTerminalMenu({ children }: { children: ReactNode }) { const [selection, setSelection] = useState(''); const terminals = useTerminals(); - const canSend = selection.length > 0 && terminals.length > 0; + const hasSelection = selection.length > 0; + const canSend = hasSelection && terminals.length > 0; return ( {children} + { + void navigator.clipboard.writeText(selection).catch((err) => { + toast.error(err instanceof Error ? err.message : 'copy failed'); + }); + }} + > + Copy + + Send to terminal diff --git a/apps/web/src/components/NewPaneMenu.tsx b/apps/web/src/components/NewPaneMenu.tsx index 4d3cd10..cc9bffa 100644 --- a/apps/web/src/components/NewPaneMenu.tsx +++ b/apps/web/src/components/NewPaneMenu.tsx @@ -29,13 +29,13 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) { onAddPane('chat')}> - New chat + New BooChat onAddPane('terminal')}> - New terminal + New BooTerm onAddPane('coder')}> - New coder + New BooCode diff --git a/apps/web/src/components/PermissionCard.tsx b/apps/web/src/components/PermissionCard.tsx new file mode 100644 index 0000000..65980bc --- /dev/null +++ b/apps/web/src/components/PermissionCard.tsx @@ -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 ( +
+
+ +
+

Permission required

+ {prompt.toolTitle && ( +

{prompt.toolTitle}

+ )} +
+ {prompt.options.map((opt) => ( + + ))} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 523aa15..1161f90 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; -import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X } from 'lucide-react'; +import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { sessionEvents } from '@/hooks/sessionEvents'; @@ -26,6 +26,7 @@ import { useViewport } from '@/hooks/useViewport'; import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import type { SidebarProject } from '@/api/types'; import { giteaUrlFor } from '@/lib/projectUrls'; +import { isCoderSessionName } from '@/lib/coder-session'; import { cn } from '@/lib/utils'; const EXPANDED_KEY = 'boocode.sidebar.expanded'; @@ -382,7 +383,11 @@ export function ProjectSidebar() { to={`/session/${s.id}`} className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`} > - + {isCoderSessionName(s.name) ? ( + + ) : ( + + )} {s.name} {relTime(s.updated_at)} diff --git a/apps/web/src/components/ProviderPicker.tsx b/apps/web/src/components/ProviderPicker.tsx deleted file mode 100644 index 3897779..0000000 --- a/apps/web/src/components/ProviderPicker.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Check, ChevronDown, Cpu } from 'lucide-react'; -import { api } from '@/api/client'; -import type { Provider } from '@/api/types'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { BottomSheet } from '@/components/BottomSheet'; -import { useViewport } from '@/hooks/useViewport'; - -interface Props { - provider: string; - model: string; - onChange: (provider: string, model: string) => void | Promise; -} - -function ProviderModelList({ - providers, - error, - currentProvider, - currentModel, - onPick, -}: { - providers: Provider[] | null; - error: string | null; - currentProvider: string; - currentModel: string; - onPick: (provider: string, model: string) => void; -}) { - if (error) { - return
{error}
; - } - if (providers === null) { - return
Loading...
; - } - - const singleProvider = providers.length === 1; - - return ( - <> - {providers.map((p) => ( -
- {!singleProvider && ( -
- {p.label} -
- )} - {p.models.map((m) => ( - - ))} -
- ))} - - ); -} - -export function ProviderPicker({ provider, model, onChange }: Props) { - const { isMobile } = useViewport(); - const [providers, setProviders] = useState(null); - const [error, setError] = useState(null); - const [open, setOpen] = useState(false); - - useEffect(() => { - if (!open || providers !== null) return; - api.coder - .providers() - .then(setProviders) - .catch((err) => - setError(err instanceof Error ? err.message : 'failed to load providers'), - ); - }, [open, providers]); - - function handlePick(prov: string, mod: string) { - setOpen(false); - void onChange(prov, mod); - } - - const currentProviderLabel = - providers?.find((p) => p.name === provider)?.label ?? provider; - - const triggerText = providers && providers.length > 1 - ? `${currentProviderLabel} / ${model}` - : model; - - if (isMobile) { - return ( - <> - - setOpen(false)} title="Provider / Model"> -
- -
-
- - ); - } - - return ( - - - - - - {error && ( -
{error}
- )} - {providers === null && !error && ( -
Loading...
- )} - {providers && providers.map((p) => { - const singleProvider = providers.length === 1; - return ( -
- {!singleProvider && ( -
- {p.label} -
- )} - {p.models.map((m) => ( - handlePick(p.name, m.id)} - className="font-mono text-xs" - > - - {m.label} - - ))} -
- ); - })} -
-
- ); -} diff --git a/apps/web/src/components/SkillSlashCommand.tsx b/apps/web/src/components/SkillSlashCommand.tsx deleted file mode 100644 index a20b282..0000000 --- a/apps/web/src/components/SkillSlashCommand.tsx +++ /dev/null @@ -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; - 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(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( - () => 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(() => { - 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 ? ( -
-
- {query ? `No skill starts with "/${query}"` : 'No skills available'} -
-
- ) : ( -
- {filtered.map((skill, i) => ( - - ))} -
- ); - - // 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); -} diff --git a/apps/web/src/components/SlashCommandPicker.tsx b/apps/web/src/components/SlashCommandPicker.tsx new file mode 100644 index 0000000..8aa085f --- /dev/null +++ b/apps/web/src/components/SlashCommandPicker.tsx @@ -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; + 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(null); + const filtered = useMemo(() => filterByPrefix(items, query), [items, query]); + + const [rect, setRect] = useState( + () => 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(() => { + 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 ? ( +
+
+ {query ? `No command starts with "/${query}"` : emptyLabel} +
+
+ ) : ( +
+ {filtered.map((item, i) => ( +
setHighlightIndex(i)} + onClick={() => onSelect(item.name)} + > +
/{item.name}
+ {item.description && ( +
+ {item.description} +
+ )} +
+ ))} +
+ ); + + return createPortal(popover, document.body); +} diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 77368c2..919504f 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; -import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react'; +import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; -import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; +import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; import { terminalsRegistry } from '@/lib/events'; @@ -34,6 +34,8 @@ interface Props { // v1.9: passed through to SettingsPane when one is mounted in the grid. session: Session; project: Project | null; + /** New BooCode opens a fresh coder session; chat/terminal split in-place. */ + onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; } export function Workspace({ @@ -45,6 +47,7 @@ export function Workspace({ chatsHook, session, project, + onAddPane, }: Props) { const { panes, @@ -59,6 +62,7 @@ export function Workspace({ showLandingPage, addSplitPane, removePane, + isPaneChatPending, handlePaneDragStart, handlePaneDragOver, handlePaneDragLeave, @@ -134,44 +138,11 @@ export function Workspace({ return out; }, [panes]); + // Per-coder-pane WS connection (status dot lives in the pane header). + const [coderConnected, setCoderConnected] = useState>({}); + return (
- {!isMobile && ( -
- - - - - - addSplitPane('chat')}> - Chat - - addSplitPane('terminal')}> - Terminal - - addSplitPane('coder')}> - Coder - - - -
- )} - - {/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header - pill (MobileTabSwitcher) is the mobile pane switcher. */} -
{ const isSettings = pane.kind === 'settings'; const isTerminal = pane.kind === 'terminal'; + const isCoder = pane.kind === 'coder'; const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact'; // v1.9: when maximized, hide every pane except the settings one. // display:none keeps the React tree mounted so streams / drafts @@ -197,9 +169,8 @@ export function Workspace({ } return null; } - // Terminal panes own their tab strip (no chats, no ChatTabBar) and - // are not drag-reorderable for now — keeps the layout grid simple. - const isChromeless = isSettings || isTerminal || isArtifact; + // Terminal + coder panes own their tab strip (no chats, no ChatTabBar). + const isChromeless = isSettings || isTerminal || isCoder || isArtifact; return (
closeAllTabs(idx)} onAddPane={(kind) => { if (kind === 'chat') void createChat(idx); - else addSplitPane(kind); + else onAddPane(kind); }} onShowHistory={() => showLandingPage(idx)} onRename={renameChat} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} /> )} + {isCoder && ( +
+ + BooCode +
+ + + + + + onAddPane('chat')}> + New BooChat + + onAddPane('terminal')}> + New BooTerm + + onAddPane('coder')}> + New BooCode + + + + + {panes.length > 1 && ( + + )} +
+
+ )} {isTerminal && (
@@ -259,14 +283,14 @@ export function Workspace({ - addSplitPane('chat')}> - New chat + onAddPane('chat')}> + New BooChat - addSplitPane('terminal')}> - New terminal + onAddPane('terminal')}> + New BooTerm - addSplitPane('coder')}> - New coder + onAddPane('coder')}> + New BooCode @@ -323,7 +347,18 @@ export function Workspace({ active={idx === activePaneIdx} /> ) : pane.kind === 'coder' ? ( - + + setCoderConnected((prev) => + prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected }, + ) + } + /> ) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? ( (); + + 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 ( +
+
+ {message.content} +
+
+ ); + } + + return ( +
+ {hasReasoning && ( +
+ Reasoning +
+            {message.reasoning_text}
+          
+
+ )} + {(hasText || (isStreaming && !hasReasoning)) && ( +
+ {hasText ? : null} + {isStreaming && ( + + )} +
+ )} + {message.status === 'failed' && ( +
message failed
+ )} +
+ ); +} + +interface Props { + messages: CoderTimelineWire[]; + footer?: ReactNode; +} + +export function CoderMessageList({ messages, footer }: Props) { + const endRef = useRef(null); + const scrollRef = useRef(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 ( +
+
+ {renderItems.map((item) => { + if (item.kind === 'message') { + return ; + } + if (item.kind === 'tool_run') { + return ; + } + return ; + })} + {footer} +
+
+
+ ); +} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index f1365a5..de318c5 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -1,16 +1,21 @@ -// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside -// BooChat's multi-pane workspace. +// BooCoder pane — chat + diff inside BooChat's multi-pane workspace. // -// Architecture: -// - REST calls go through /api/coder/* which BooChat's server proxies to -// the boocoder container at http://boocoder:3000/api/* -// - WS connects directly to the boocoder container at :9502 (same Tailscale -// network, no CORS for WebSocket). In dev, the Vite proxy handles it. +// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502). +// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502). -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Code, Send, Check, X, RefreshCw } from 'lucide-react'; -import { MarkdownRenderer } from '@/components/MarkdownRenderer'; -import { ProviderPicker } from '@/components/ProviderPicker'; +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'; // --------------------------------------------------------------------------- @@ -22,16 +27,26 @@ interface CoderMessage { role: 'user' | 'assistant' | 'system'; content: string; status?: 'streaming' | 'complete' | 'failed'; + reasoning_text?: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string }; }>; - tool_results?: { +} + +interface CoderToolMessage { + id: string; + role: 'tool'; + tool_results: { tool_call_id: string; - content: string; + output: unknown; + truncated?: boolean; + error?: string; }; } +type CoderTimelineMessage = CoderMessage | CoderToolMessage; + interface PendingChange { id: string; file_path: string; @@ -43,24 +58,106 @@ interface PendingChange { interface Props { sessionId: string; + paneId: string; + chatId?: string; + chatPending?: boolean; + projectPath?: string; + onConnectedChange?: (connected: boolean) => void; } -// --------------------------------------------------------------------------- -// Hooks -// --------------------------------------------------------------------------- +interface WsHandlers { + onPermissionRequested?: (prompt: PermissionPrompt) => void; + onPermissionResolved?: (taskId: string) => void; + onAssistantComplete?: () => void; + onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void; + onConnectedChange?: (connected: boolean) => void; +} -function useCoderMessages(sessionId: string) { - const [messages, setMessages] = useState([]); +type RawCoderMessage = { + id: string; + role: string; + chat_id?: string; + content?: string | null; + status?: string | null; + reasoning_text?: string; + reasoning_parts?: Array<{ text?: string }> | null; + tool_results?: { + tool_call_id: string; + output: unknown; + truncated?: boolean; + error?: string; + } | null; + tool_calls?: Array< + | { id: string; name: string; args?: Record } + | { 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([]); const [connected, setConnected] = useState(false); const wsRef = useRef(null); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + const chatIdRef = useRef(chatId); + chatIdRef.current = chatId; + + const loadMessages = useCallback(() => { + if (!chatId) { + setMessages([]); + return Promise.resolve(); + } + return api.coder + .listMessages(sessionId, chatId) + .then((rows) => + setMessages( + rows + .map(mapCoderTimelineRow) + .filter((m): m is CoderTimelineMessage => m !== null), + ), + ) + .catch(() => {/* boocoder may be down */}); + }, [sessionId, chatId]); useEffect(() => { - // Fetch existing messages on mount - fetch(`/api/coder/sessions/${sessionId}/messages`) - .then((res) => res.ok ? res.json() : []) - .then((data: CoderMessage[]) => setMessages(data)) - .catch(() => {/* noop — coder backend may not be running */}); - }, [sessionId]); + void loadMessages(); + }, [loadMessages]); useEffect(() => { // WS connects to the coder backend. In production, this goes through the @@ -77,38 +174,137 @@ function useCoderMessages(sessionId: string) { ws.onmessage = (ev) => { try { const frame = JSON.parse(ev.data as string); - if (frame.type === 'message_started') { - setMessages((prev) => [ - ...prev, - { id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' }, - ]); + const scopedChatId = chatIdRef.current; + if ( + scopedChatId && + frame.chat_id && + frame.chat_id !== scopedChatId && + frame.type !== 'snapshot' + ) { + return; + } + if (frame.type === 'snapshot' && Array.isArray(frame.messages)) { + const rawMessages = (frame.messages as RawCoderMessage[]).filter( + (m) => !scopedChatId || m.chat_id === scopedChatId, + ); + setMessages( + rawMessages + .map(mapCoderTimelineRow) + .filter((m): m is CoderTimelineMessage => m !== null), + ); + } else if (frame.type === 'message_started') { + setMessages((prev) => { + if (prev.some((m) => m.id === frame.message_id)) return prev; + const role = frame.role ?? 'assistant'; + const tempIdx = + role === 'user' + ? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user') + : -1; + if (tempIdx >= 0) { + return prev.map((m, i) => + i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m, + ); + } + return [ + ...prev, + { id: frame.message_id, role, content: '', status: 'streaming' }, + ]; + }); } else if (frame.type === 'delta') { setMessages((prev) => - prev.map((m) => - m.id === frame.message_id - ? { ...m, content: m.content + (frame.content ?? '') } - : m - ) + prev.map((m) => { + if (m.id !== frame.message_id || m.role === 'tool') return m; + const chunk = frame.content ?? ''; + if (m.role === 'user') { + return { ...m, content: chunk || m.content }; + } + return { ...m, content: m.content + chunk }; + }), ); } else if (frame.type === 'message_complete') { - setMessages((prev) => - prev.map((m) => - m.id === frame.message_id ? { ...m, status: 'complete' } : m - ) - ); + setMessages((prev) => { + const completed = prev.find( + (m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant', + ); + const next = prev.map((m) => + m.id === frame.message_id && m.role !== 'tool' + ? { ...m, status: 'complete' as const } + : m, + ); + if (completed) { + queueMicrotask(() => handlersRef.current.onAssistantComplete?.()); + } + return next; + }); } else if (frame.type === 'tool_call') { + const tc = frame.tool_call as { id: string; name: string; args?: Record } | undefined; + if (tc?.id) { + setMessages((prev) => + prev.map((m) => + m.role !== 'assistant' || m.id !== frame.message_id + ? m + : { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) }, + ), + ); + } + } else if (frame.type === 'tool_result') { + setMessages((prev) => { + const exists = prev.some((m) => m.id === frame.tool_message_id); + if (exists) { + return prev.map((m) => + m.role === 'tool' && m.id === frame.tool_message_id + ? { + ...m, + tool_results: { + tool_call_id: frame.tool_call_id, + output: frame.output, + truncated: frame.truncated, + ...(frame.error ? { error: frame.error } : {}), + }, + } + : m, + ); + } + return [ + ...prev, + { + id: frame.tool_message_id, + role: 'tool' as const, + tool_results: { + tool_call_id: frame.tool_call_id, + output: frame.output, + truncated: frame.truncated, + ...(frame.error ? { error: frame.error } : {}), + }, + }, + ]; + }); + } else if (frame.type === 'reasoning_delta') { setMessages((prev) => prev.map((m) => - m.id === frame.message_id - ? { - ...m, - tool_calls: [ - ...(m.tool_calls ?? []), - { id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } }, - ], - } - : m - ) + m.id === frame.message_id && m.role === 'assistant' + ? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') } + : m, + ), + ); + } else if (frame.type === 'permission_requested') { + handlersRef.current.onPermissionRequested?.({ + taskId: frame.task_id, + toolTitle: frame.tool_title, + options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({ + optionId: o.option_id, + label: o.label, + })), + }); + } else if (frame.type === 'permission_resolved') { + handlersRef.current.onPermissionResolved?.(frame.task_id); + } else if (frame.type === 'agent_commands') { + handlersRef.current.onAgentCommands?.( + frame.task_id, + (frame.commands ?? []).map((c: { name: string; description?: string }) => ({ + name: c.name, + description: c.description, + })), ); } } catch { @@ -122,7 +318,11 @@ function useCoderMessages(sessionId: string) { }; }, [sessionId]); - return { messages, setMessages, connected }; + useEffect(() => { + handlersRef.current.onConnectedChange?.(connected); + }, [connected]); + + return { messages, setMessages, connected, loadMessages }; } function usePendingChanges(sessionId: string) { @@ -165,48 +365,6 @@ function usePendingChanges(sessionId: string) { // Sub-components // --------------------------------------------------------------------------- -function CoderMessageBubble({ message }: { message: CoderMessage }) { - const isUser = message.role === 'user'; - return ( -
-
- {isUser ? ( -

{message.content}

- ) : ( -
- -
- )} - {message.tool_calls && message.tool_calls.length > 0 && ( -
- {message.tool_calls.map((tc) => ( -
- {tc.function.name} - {tc.function.arguments && ( - - ({tc.function.arguments.slice(0, 80)} - {tc.function.arguments.length > 80 ? '...' : ''}) - - )} -
- ))} -
- )} - {message.status === 'streaming' && ( - - )} -
-
- ); -} - function DiffPanel({ changes, loading, @@ -296,115 +454,272 @@ function DiffPanel({ // Main component // --------------------------------------------------------------------------- -export function CoderPane({ sessionId }: Props) { - const { messages, setMessages, connected } = useCoderMessages(sessionId); +export function CoderPane({ + sessionId, + paneId, + chatId, + chatPending = false, + projectPath, + onConnectedChange, +}: Props) { + const [agentConfig, setAgentConfig] = useState({ + provider: 'boocode', + model: '', + modeId: null, + thinkingOptionId: null, + }); + const [activeTaskId, setActiveTaskId] = useState(null); + const [permissionPrompt, setPermissionPrompt] = useState(null); + const [permissionBusy, setPermissionBusy] = useState(false); + const [providerCommands, setProviderCommands] = useState([]); + const [liveTaskCommands, setLiveTaskCommands] = useState([]); + const { skills } = useSkills(); + const [slashState, setSlashState] = useState<{ query: string } | null>(null); + + const displayedCommands = useMemo(() => { + const base = + agentConfig.provider === 'boocode' + ? skills.map((s) => ({ name: s.name, description: s.description })) + : providerCommands; + return mergeCommandsByName(base, liveTaskCommands); + }, [agentConfig.provider, skills, providerCommands, liveTaskCommands]); + + const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]); + const commandsByName = useMemo( + () => new Set(displayedCommands.map((c) => c.name)), + [displayedCommands], + ); + + const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, { + onConnectedChange, + onPermissionRequested: (prompt) => { + setActiveTaskId(prompt.taskId); + setPermissionPrompt(prompt); + }, + onPermissionResolved: (taskId) => { + if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) { + setPermissionPrompt(null); + } + }, + onAssistantComplete: () => { + setActiveTaskId(null); + setPermissionPrompt(null); + setLiveTaskCommands([]); + }, + onAgentCommands: (_taskId, commands) => { + setLiveTaskCommands(commands); + }, + }); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); - const [provider, setProvider] = useState('boocode'); - const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4'); - const messagesEndRef = useRef(null); const inputRef = useRef(null); - // Auto-scroll on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - // Refresh pending changes when a message_complete arrives useEffect(() => { - const lastMsg = messages[messages.length - 1]; - if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') { + const lastAssistant = [...messages].reverse().find( + (m): m is CoderMessage => m.role === 'assistant', + ); + if (lastAssistant?.status === 'complete') { refresh(); } }, [messages, refresh]); + // Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth) + useEffect(() => { + if (!activeTaskId || connected) return; + const interval = setInterval(() => { + if (!permissionPrompt) { + void api.coder + .getTaskPermission(activeTaskId) + .then((prompt) => { + setPermissionPrompt({ + taskId: prompt.taskId, + toolTitle: prompt.toolTitle, + options: prompt.options, + }); + }) + .catch(() => {/* no pending permission */}); + } + void api.coder + .getTaskCommands(activeTaskId) + .then((res) => setLiveTaskCommands(res.commands)) + .catch(() => {/* not cached yet */}); + void api.coder + .getTask(activeTaskId) + .then((task) => { + if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') { + return; + } + setActiveTaskId(null); + setPermissionPrompt(null); + setLiveTaskCommands([]); + void loadMessages(); + }) + .catch(() => {/* task gone */}); + }, 2000); + return () => clearInterval(interval); + }, [activeTaskId, connected, permissionPrompt, loadMessages]); + + const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => { + setProviderCommands(commands); + }, []); + + const handlePermissionRespond = useCallback(async (optionId: string | null) => { + if (!permissionPrompt) return; + setPermissionBusy(true); + try { + await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId); + setPermissionPrompt(null); + } finally { + setPermissionBusy(false); + } + }, [permissionPrompt]); + const handleSend = useCallback(async () => { const text = input.trim(); - if (!text || sending) return; + if (!text || sending || !chatId) return; + + if (text.startsWith('/')) { + const parsed = parseSlashInput(text); + if (parsed) { + const { cmdName, args } = parsed; + if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) { + setInput(''); + setSlashState(null); + setSending(true); + setPermissionPrompt(null); + setLiveTaskCommands([]); + try { + await api.coder.skillInvoke( + sessionId, + paneId, + cmdName, + args.length > 0 ? args : null, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'skill invocation failed'); + } finally { + setSending(false); + } + return; + } + if (!commandsByName.has(cmdName)) { + // Unknown slash — fall through and send as literal text. + } + } + } setInput(''); + setSlashState(null); setSending(true); + setPermissionPrompt(null); + setLiveTaskCommands([]); - // Optimistic user message const tempId = `temp-${Date.now()}`; setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]); try { - const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: text, - provider: provider !== 'boocode' ? provider : undefined, - model: model || undefined, - }), + const data = await api.coder.sendMessage(sessionId, { + content: text, + pane_id: paneId, + chat_id: chatId, + provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined, + model: agentConfig.model || undefined, + mode_id: agentConfig.modeId ?? undefined, + thinking_option_id: agentConfig.thinkingOptionId ?? undefined, }); - if (res.ok) { - const data = await res.json(); - // Replace temp message with real one if server returned it - if (data.user_message_id) { - setMessages((prev) => - prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m) - ); - } + if (data.user_message_id) { + setMessages((prev) => + prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m)) + ); } - } catch { - // The WS will bring the real messages; optimistic is good enough + if (data.task_id) { + setActiveTaskId(data.task_id); + } else { + setActiveTaskId(null); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to send'); } finally { setSending(false); } - }, [input, sending, sessionId, provider, model, setMessages]); + }, [ + input, + sending, + sessionId, + paneId, + chatId, + agentConfig, + skillsByName, + commandsByName, + setMessages, + ]); + + const handleSlashSelect = useCallback((name: string) => { + const next = `/${name} `; + setInput(next); + setSlashState(null); + requestAnimationFrame(() => { + const ta = inputRef.current; + if (ta) { + ta.selectionStart = ta.selectionEnd = next.length; + ta.focus(); + } + }); + }, []); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + setInput(newValue); + if (isSlashCommandToken(newValue)) { + setSlashState({ query: slashQuery(newValue) }); + } else { + setSlashState(null); + } + }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (slashState) return; + if (e.nativeEvent.isComposing) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSend(); } }, - [handleSend] + [handleSend, slashState] ); return (
- {/* Header */} -
- - { - setProvider(prov); - setModel(mod); - }} - /> - -
- - {/* Chat area */} -
+ {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */} +
{messages.length === 0 ? ( -
+
-

Send a message to start coding

+

{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}

) : ( -
- {messages.map((msg) => ( - - ))} -
-
+ Agent running…

+ ) : undefined + } + /> )}
+ {permissionPrompt && ( + void handlePermissionRespond(id)} + busy={permissionBusy} + /> + )} + {/* Diff panel — only shows when there are pending changes */} {changes.filter((c) => c.status === 'pending').length > 0 && (
@@ -418,28 +733,46 @@ export function CoderPane({ sessionId }: Props) {
)} - {/* Input */} -
+ {/* Composer + input */} +
+ {displayedCommands.length > 0 && } + +