Compare commits
3 Commits
v2.0.5
...
v2.2-paseo
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d3f86c2b | |||
| 04673eaf59 | |||
| d8ffee1950 |
@@ -1,6 +1,6 @@
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
|
||||
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boochat
|
||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||
PROJECT_ROOT_WHITELIST=/opt
|
||||
BOOTSTRAP_ROOT=/opt/projects
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
|
||||
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorignore
|
||||
CLAUDE.local.md
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
109
AGENTS.md
Normal file
109
AGENTS.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Agent navigation
|
||||
|
||||
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
|
||||
|
||||
Last updated: 2026-05-25
|
||||
|
||||
## Doc map
|
||||
|
||||
| Need | Read |
|
||||
|------|------|
|
||||
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
|
||||
| Read-only chat behavior | `BOOCHAT.md` |
|
||||
| Write tools, dispatch, pending changes | `BOOCODER.md` |
|
||||
| Shipped vs planned, version order | `boocode_roadmap.md` |
|
||||
| Latest release truth | `CHANGELOG.md` (top entry = current) |
|
||||
| System diagram + data flow | `docs/ARCHITECTURE.md` |
|
||||
| Current focus / blockers | `CURRENT.md` |
|
||||
| Batch convention | `openspec/README.md` |
|
||||
| Shipped batch snapshots | `openspec/changes/archived/` |
|
||||
| Chat agent personas + tool lists | `data/AGENTS.md` |
|
||||
| External repo lift inventory | `boocode_code_review.md` |
|
||||
|
||||
## Monorepo layout (actual)
|
||||
|
||||
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
|
||||
|
||||
| Surface | Packages | Port | Deploy |
|
||||
|---------|----------|------|--------|
|
||||
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
|
||||
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
|
||||
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
|
||||
|
||||
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
|
||||
|
||||
## Task routing
|
||||
|
||||
| Task type | Start here |
|
||||
|-----------|------------|
|
||||
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
|
||||
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
|
||||
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
|
||||
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
|
||||
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
|
||||
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
|
||||
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
|
||||
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
|
||||
|
||||
## Verification (before claiming done)
|
||||
|
||||
```bash
|
||||
pnpm -C apps/server test && pnpm -C apps/server build
|
||||
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
|
||||
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
|
||||
curl http://100.114.205.53:9502/api/health # BooCoder on host
|
||||
```
|
||||
|
||||
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
|
||||
|
||||
## Hard rules (from CLAUDE.md)
|
||||
|
||||
- **Do not commit or push** unless Sam explicitly asks.
|
||||
- **No app-layer auth** — Authelia at the reverse proxy.
|
||||
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
|
||||
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
|
||||
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
|
||||
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
|
||||
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
|
||||
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
|
||||
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
|
||||
|
||||
## Using openspec with Cursor
|
||||
|
||||
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
|
||||
|
||||
### When starting a batch
|
||||
|
||||
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
|
||||
2. Write `proposal.md` — why, scope, non-goals, dependencies.
|
||||
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
|
||||
4. Optional `design.md` — schema/API decisions that outlive the batch.
|
||||
|
||||
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
|
||||
|
||||
### Prompting an agent
|
||||
|
||||
```
|
||||
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
|
||||
Implement tasks 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.
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -2,10 +2,62 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.2.1-pane-scoped-chats — 2026-05-26
|
||||
|
||||
Follow-up fixes on the v2.2 Paseo provider stack. Pane-scoped chat resolution: `resolveChatId(sql, sessionId, paneId)` reads `sessions.workspace_panes`, requires `pane_id` on coder POST routes, and creates a scoped chat per coder/terminal pane instead of falling back to the session's first open chat (which fused BooCoder writes into the BooChat pane). Client `useWorkspacePanes` seeds new coder/terminal panes with dedicated chats on create, hydrate, and workspace sync; `CoderPane` blocks send until seeded and filters WS frames + `GET /messages?chat_id=` to that chat. External-agent tool UI: new `CoderMessageList` renders BooChat-style `ToolCallLine` timeline (tools before answer text on combined ACP rows). WS user-delta handling replaces content instead of appending (fixes garbled duplicate user messages when optimistic UI met full-body deltas). BooChat inference: `buildMessagesPayload` strips orphan assistant `tool_calls` without matching `tool` rows and skips stray tool rows when the owning assistant turn is incomplete (fixes "Tool results are missing for tool calls" on shared chats with ACP history). Pairs with `v2.2-paseo-providers`.
|
||||
|
||||
## v2.2-paseo-providers — 2026-05-26
|
||||
|
||||
Paseo-equivalent provider stack for BooCoder. Seven providers (boocode, cursor, claude, opencode, goose, qwen, copilot) with snapshot API (`provider-snapshot.ts`, ACP cold probe, per-provider model merge, cursor models from ACP). Frontend `AgentComposerBar` replaces `ProviderPicker` — provider / mode / model / thinking in the coder composer; `SlashCommandPicker` + `useProviderSnapshot` hook. ACP dispatch rewritten (`acp-dispatch.ts`, `acp-stream.ts`, `acp-spawn.ts`, `agent-turn-persist.ts`, `acp-tool-snapshot.ts`) with Paseo merge/stream/persist pattern, inline `PermissionCard` prompts, and `reasoning_delta` WS frames. Agent slash-command hints via ACP `available_commands_update` cached in `agent-commands-cache.ts` + `AgentCommandsHint`. Arena and MCP entry points accept `mode_id` / `thinking_option_id`. SSH helpers removed; all host exec via `host-exec.ts` direct spawn. Server adds coder proxy route + shared skill invoke. New tests: acp-derive, acp-tool-snapshot, cursor-models, provider-commands, provider-snapshot, agents. Docs: `AGENTS.md`, `docs/ARCHITECTURE.md`, openspec `v2-2-paseo-providers`.
|
||||
|
||||
## v2.1.1-roadmap-cleanup — 2026-05-25
|
||||
|
||||
Roadmap reconciliation, README updates, and openspec archive housekeeping. No runtime behavior changes.
|
||||
|
||||
## v2.1.0-provider-picker — 2026-05-25
|
||||
|
||||
Provider picker: BooCoder moves from Docker container to host systemd service (`boocoder.service`). All agent dispatch (ACP + PTY) switches from SSH tunnel to direct `spawn`/`exec` — no more `sshSpawn`/`sshExec`/`sshSpawnWithStdin` (marked `@deprecated`). New provider registry (`provider-registry.ts`) with 5 providers (boocode, opencode, goose, claude, qwen), per-provider model discovery (llama-swap for ACP agents, `~/.qwen/settings.json` for qwen, static for claude), and `agent-probe.ts` runs direct `which`/`exec` instead of SSH. `GET /api/providers` route assembles the provider list with installed status, models, and transport (ACP→PTY fallback if `supports_acp` is false). Frontend `ProviderPicker` component in CoderPane header lets users pick provider/model per message; messages route through `tasks` row for external providers instead of inference enqueue. Smart scroll: `MessageList` only auto-scrolls when user is near bottom (150px threshold). DB schema adds `models`, `label`, `transport` columns to `available_agents`. Bug fixes: `loadContext` SELECT now includes `allowed_read_paths` (cross-repo read grants were silently failing), cap hit sentinel insertion moved before `buildMessagesPayload` call.
|
||||
|
||||
## v2.0.5 — 2026-05-25
|
||||
|
||||
FAST_MODEL routing: optional `FAST_MODEL` env var routes cheaper models (titles, summaries, labeling) to a small model on llama-swap (e.g. `nemotron-nano-4b`) instead of loading the 35B for 20-token calls. Falls back to session model or DEFAULT_MODEL. Tool-use summaries: `runCapHitSummary` now writes the cap_hit sentinel before building the summary payload (bug fix — sentinel was written after, causing it to appear after the summary text in the message list). Qwen Code dispatch: `qwen -p "<task>" --output-format stream-json` via PTY (non-interactive mode, no `--yolo` flag needed). Arena: `POST /api/arena` dispatches the same task to N models/agents in parallel, each with its own task + worktree; `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks winner.
|
||||
|
||||
## v2.0.4-hardening — 2026-05-25
|
||||
|
||||
Path-guard fuzz suite: 25+ traversal-attack tests covering ../ sequences (all depths), encoded traversal (%2e%2e), null byte injection, absolute path escape, prefix-without-separator, backslash traversal, and the full secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc). Plus 5 valid-path positive tests confirming normal writes aren't blocked and 5 edge-case tests (empty, whitespace-only, very long path, triple-dot, multiple slashes). Null-byte and whitespace-only guards added to `resolveWritePath` (previously only checked empty string). DB-integration test skeleton for pending_changes full-cycle (queue create/edit/delete, apply, rewind) gated on DATABASE_URL via `describe.runIf`. Production readiness verified: all services healthy, all builds clean, 57 tests passing (23 existing + 34 new).
|
||||
|
||||
## v2.0.3 — 2026-05-25
|
||||
|
||||
CLI client (`apps/coder/src/cli.ts`, 249 lines) for headless agent interaction. Human inbox view (`human_inbox` view) surfaces tasks in `blocked`/`failed` state. Cost tracking: `tool_cost_stats` view with per-tool 100-call rolling window. `new_task` tool (Boomerang pattern): creates tasks with project context and optional arena contestants. `check_task_status` and `list_tasks` tools for task lifecycle management. Stats routes (`GET /api/stats`) for cost aggregation. Dispatcher extended to support new task states.
|
||||
|
||||
## v2.0.2 — 2026-05-25
|
||||
|
||||
BooCoder MCP server (`mcp-server.ts`, 201 lines) exposing 6 write-capable tools over stdio: `edit_file`, `create_file`, `delete_file`, `view_pending_changes`, `apply_pending`, `rewind`. Registered in `apps/coder/src/index.ts` as an MCP stdio server. Enables external agents (opencode, claude, qwen) to call BooCoder's write tools through the MCP protocol.
|
||||
|
||||
## v2.0.1 — 2026-05-25
|
||||
|
||||
ACP dispatch (`acp-dispatch.ts`, 271 lines): runs ACP-capable agents (opencode, goose) via SSH tunnel wrapping stdio into NDJSON streams for `@agentclientprotocol/sdk` JSON-RPC sessions. PTY dispatch (`pty-dispatch.ts`, 139 lines): runs non-ACP agents (claude, qwen) via SSH with stdin pipe for non-interactive mode. Worktree management (`worktrees.ts`, 118 lines): per-task git worktree creation and cleanup. SSH helper (`ssh.ts`, 126 lines): `sshSpawn`, `sshExec`, `sshSpawnWithStdin` for host command execution. Dispatcher extended to route tasks to ACP vs PTY based on agent capability. Agent probe updated to verify ACP support.
|
||||
|
||||
## v2.0.0-final — 2026-05-25
|
||||
|
||||
Dispatcher (`dispatcher.ts`, 191 lines): task queue with polling loop, Path A (native inference) and Path B (external agent dispatch). Task routes (`tasks.ts`, 138 lines): CRUD for tasks with state transitions. Agent probe (`agent-probe.ts`, 51 lines): startup scan of host for installed agents (opencode, goose, claude, pi, qwen), version detection, ACP capability verification. Schema adds `tasks` table. CLAUDE.md updated with v2.0.0 architecture docs covering BooCoder, DB rename, MCP config, workspace deps.
|
||||
|
||||
## v2.0.0 — 2026-05-25
|
||||
|
||||
BooCoder frontend: `CoderPane.tsx` (432 lines) as a `'coder'` pane type within BooChat's SPA — chat pane + diff pane (pending changes) + session picker. Standalone fallback SPA in `apps/coder/web/` (Vite + React) served at `:9502` directly. Session streaming via `useSessionStream` WS hook. API client with typed endpoints. Workspace pane persistence via `useWorkspacePanes`. Server routes for pending changes (`PATCH/POST /api/coder/sessions/:id/pending`). Verification discipline rules + chat naming from assistant response.
|
||||
|
||||
## v2.0.0-beta — 2026-05-25
|
||||
|
||||
Write tools: `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` — queue in `pending_changes` table, nothing hits disk until applied. `write_guard.ts` validates paths (resolve + prefix-check, no realpath for creates). Inference loop integration via `inference_context.ts` (bridges inference turn state to tool execution). API routes: `messages.ts` (POST /api/coder/sessions/:id/messages), `pending.ts` (GET/POST /api/coder/sessions/:id/pending). WebSocket support (`ws.ts`) for real-time pending changes updates. Tool adapter (`adapter.ts`) converts inference tool calls to tool execution. Write guard tests (115 lines). Server-side inference loop wired to BooCoder tools.
|
||||
|
||||
## v2.0.0-alpha — 2026-05-25
|
||||
|
||||
BooCoder foundation: Docker container (`apps/coder/Dockerfile`), docker-compose service, host env file. Schema: `sessions`, `chats`, `messages`, `pending_changes`, `tasks`, `message_parts` tables. DB renamed from `boocode` to `boochat`. Config module, PostgreSQL connection (porsager/postgres). Initial Fastify server with health endpoint. BOOCODER.md guidance file. Implementation plan (8 phases). Proposal updated with AGENTS.md extensions, Boomerang pattern, observation hooks.
|
||||
|
||||
## v2.0-proposal — 2026-05-24
|
||||
|
||||
v2.0 proposal: BooCoder write tools, pending-changes queue, ACP dispatch, MCP server. Openspec proposal (`proposal.md`, 274 lines) and task breakdown (`tasks.md`, 130 lines) defining the v2.0 feature scope — write-capable coding agent with file operations, external agent dispatch via ACP/PTY, and MCP server for tool exposure.
|
||||
|
||||
## v1.16.0-codesight-merge — 2026-05-24
|
||||
|
||||
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
||||
|
||||
27
CLAUDE.md
27
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).
|
||||
@@ -66,16 +68,24 @@ Key services:
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||
|
||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||
|
||||
### BooCoder (`apps/coder/src/`)
|
||||
|
||||
- Write-capable coding agent. Separate Fastify server at port 9502, same docker network (`boocode_net`).
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST (Dockerfile builds server → coder).
|
||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to `http://boocoder:3000/api/*`. WS connects directly to `:9502`.
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
|
||||
### Frontend (`apps/web/src/`)
|
||||
|
||||
@@ -122,7 +132,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
|
||||
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`.
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
|
||||
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
|
||||
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch.
|
||||
- Arena (v2.0.5): `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree. `GET /api/arena/:id` for results. `POST /api/arena/:id/select/:task_id` picks winner.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -133,7 +147,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||
@@ -169,4 +183,5 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailsc
|
||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`.
|
||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
||||
|
||||
10
CURRENT.md
Normal file
10
CURRENT.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Current focus
|
||||
|
||||
Last updated: 2026-05-26
|
||||
|
||||
- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
|
||||
- **Branch:** `main`
|
||||
- **Blockers:** none
|
||||
- **Last shipped:** `v2.2.1-pane-scoped-chats` (pairs with `v2.2-paseo-providers` on same commit)
|
||||
|
||||
Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state.
|
||||
27
README.md
27
README.md
@@ -1,6 +1,8 @@
|
||||
# boocode
|
||||
|
||||
Self-hosted single-user developer chat app. v1: chat only.
|
||||
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
|
||||
|
||||
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -13,6 +15,8 @@ Self-hosted single-user developer chat app. v1: chat only.
|
||||
|
||||
- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools
|
||||
- `apps/web` — React frontend; served by Fastify in production, Vite in dev
|
||||
- `apps/booterm` — Fastify + node-pty + tmux for in-browser terminal panes
|
||||
- `apps/coder` — Fastify write tools + ACP/PTY dispatcher + MCP server (BooCoder)
|
||||
|
||||
## Local dev
|
||||
|
||||
@@ -28,7 +32,7 @@ cp .env.example .env
|
||||
docker compose up -d boocode_db
|
||||
|
||||
# run server (port 3000) and web (port 5173) in two shells
|
||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \
|
||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat \
|
||||
LLAMA_SWAP_URL=http://100.101.41.16:8401 \
|
||||
pnpm dev:server
|
||||
|
||||
@@ -49,11 +53,18 @@ docker compose up --build -d
|
||||
Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the
|
||||
upstream and inject `Remote-User`. Postgres binds loopback only.
|
||||
|
||||
## What v1 has
|
||||
## Services
|
||||
|
||||
Project sidebar, sessions per project, chat with streaming responses over
|
||||
WebSocket, four file-read tools scoped to the project root (`view_file`,
|
||||
`list_dir`, `grep`, `find_files`), and a model picker driven by llama-swap's
|
||||
`/v1/models`.
|
||||
|Service|Port|Description|
|
||||
|---|---|---|
|
||||
|BooChat|`100.114.205.53:9500`|Read-only chat + SPA |
|
||||
|BooTerm|`100.114.205.53:9501`|PTY/tmux terminal panes |
|
||||
|BooCoder|host:9502|Write tools + agent dispatch + MCP server (systemd service, not Docker) |
|
||||
|Postgres|`127.0.0.1:5500`|Shared database (`boochat`; Docker service `boocode_db`) |
|
||||
|codecontext|internal `:8080`|Code graph sidecar (Docker network only) |
|
||||
|
||||
What v1 does not have lives in v2 (terminal pane) and v3 (Coder pane).
|
||||
## What's shipped
|
||||
|
||||
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (Context7 + multi-server), tool-cost tracking, skills system, agent registry, provider picker with model discovery
|
||||
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
|
||||
- **BooCoder**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, ACP/PTY dual-path agent dispatch, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite
|
||||
|
||||
15
apps/coder/.env.host
Normal file
15
apps/coder/.env.host
Normal file
@@ -0,0 +1,15 @@
|
||||
NODE_ENV=production
|
||||
PORT=9502
|
||||
HOST=100.114.205.53
|
||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
|
||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||
PROJECT_ROOT_WHITELIST=/opt
|
||||
BOOTSTRAP_ROOT=/opt/projects
|
||||
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
|
||||
LOG_LEVEL=info
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
GITEA_BASE_URL=https://git.indifferentketchup.com
|
||||
GITEA_USER=indifferentketchup
|
||||
GITEA_SSH_HOST=100.114.205.53:2222
|
||||
MCP_CONFIG_PATH=/data/mcp.json
|
||||
SKILLS_ROOT=/opt/boocode/data/skills
|
||||
@@ -23,15 +23,20 @@ import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
import { registerPendingRoutes } from './routes/pending.js';
|
||||
import { registerTaskRoutes } from './routes/tasks.js';
|
||||
import { registerInboxRoutes } from './routes/inbox.js';
|
||||
import { registerStatsRoutes } from './routes/stats.js';
|
||||
import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
// Phase 4: dispatcher + agent probe
|
||||
import { createDispatcher } from './services/dispatcher.js';
|
||||
import { probeAgents } from './services/agent-probe.js';
|
||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
async function main() {
|
||||
// MCP mode: stdio transport, no HTTP server
|
||||
@@ -71,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
|
||||
@@ -133,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();
|
||||
@@ -140,11 +180,13 @@ 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);
|
||||
registerStatsRoutes(app, sql);
|
||||
registerArenaRoutes(app, sql);
|
||||
registerProviderRoutes(app, sql, config);
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// Serve static frontend (built web app). In production, the dist/ is
|
||||
|
||||
@@ -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<TaskRow[]>`
|
||||
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
|
||||
|
||||
81
apps/coder/src/routes/chat-resolve.ts
Normal file
81
apps/coder/src/routes/chat-resolve.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
interface WorkspacePaneRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
chatId?: string;
|
||||
chatIds?: string[];
|
||||
activeChatIdx?: number;
|
||||
}
|
||||
|
||||
function chatNameForKind(kind: string): string {
|
||||
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
||||
if (kind === 'terminal') return 'Terminal';
|
||||
return 'Chat';
|
||||
}
|
||||
|
||||
function activeChatIdForPane(pane: WorkspacePaneRow): string | undefined {
|
||||
const chatIds = pane.chatIds ?? [];
|
||||
const idx = pane.activeChatIdx ?? 0;
|
||||
if (idx >= 0 && idx < chatIds.length) return chatIds[idx];
|
||||
return pane.chatId;
|
||||
}
|
||||
|
||||
/** Resolve the active chat for a workspace pane; auto-seed when empty. */
|
||||
export async function resolveChatId(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
): Promise<string | null> {
|
||||
return sql.begin(async (tx) => {
|
||||
const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>`
|
||||
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
|
||||
`;
|
||||
if (sessionRows.length === 0) return null;
|
||||
|
||||
const panes = sessionRows[0]!.workspace_panes ?? [];
|
||||
const paneIdx = panes.findIndex((p) => p.id === paneId);
|
||||
if (paneIdx < 0) return null;
|
||||
|
||||
const pane = panes[paneIdx]!;
|
||||
const existingChatId = activeChatIdForPane(pane);
|
||||
if (existingChatId) {
|
||||
const chatRows = await tx<{ id: string }[]>`
|
||||
SELECT id FROM chats
|
||||
WHERE id = ${existingChatId}
|
||||
AND session_id = ${sessionId}
|
||||
AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length > 0) return existingChatId;
|
||||
}
|
||||
|
||||
const [newChat] = await tx<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, ${chatNameForKind(pane.kind)}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
if (!newChat) return null;
|
||||
|
||||
const nextChatIds = [...(pane.chatIds ?? []), newChat.id];
|
||||
const nextActiveIdx = nextChatIds.length - 1;
|
||||
const nextPanes = panes.map((p, i) =>
|
||||
i === paneIdx
|
||||
? {
|
||||
...p,
|
||||
chatIds: nextChatIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: newChat.id,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
await tx`
|
||||
UPDATE sessions
|
||||
SET workspace_panes = ${tx.json(nextPanes as never)},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
`;
|
||||
|
||||
return newChat.id;
|
||||
});
|
||||
}
|
||||
@@ -3,10 +3,16 @@ import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
chat_id: z.string().uuid(),
|
||||
pane_id: z.string().min(1).max(200),
|
||||
chat_id: z.string().uuid().optional(),
|
||||
provider: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
mode_id: z.string().max(200).optional(),
|
||||
thinking_option_id: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
@@ -15,12 +21,100 @@ interface InferenceApi {
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
role: string;
|
||||
content: string | null;
|
||||
status: string | null;
|
||||
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
reasoning_parts: Array<{ text?: string }> | null;
|
||||
}
|
||||
|
||||
function mapCoderMessageRow(row: MessageRow) {
|
||||
if (row.role === 'tool') {
|
||||
if (!row.tool_results?.tool_call_id) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
role: 'tool' as const,
|
||||
tool_results: row.tool_results,
|
||||
};
|
||||
}
|
||||
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
|
||||
return null;
|
||||
}
|
||||
const tool_calls = row.tool_calls?.map((tc) => ({
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
}));
|
||||
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
|
||||
return {
|
||||
id: row.id,
|
||||
role: row.role as 'user' | 'assistant' | 'system',
|
||||
content: row.content ?? '',
|
||||
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
|
||||
...(reasoningText ? { reasoning_text: reasoningText } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
inference: InferenceApi,
|
||||
): void {
|
||||
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
|
||||
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const chatId = req.query.chat_id;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
if (chatId) {
|
||||
const chatRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats
|
||||
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
}
|
||||
|
||||
const rows = chatId
|
||||
? await sql<MessageRow[]>`
|
||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`
|
||||
: await sql<MessageRow[]>`
|
||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`;
|
||||
|
||||
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
@@ -32,73 +126,96 @@ export function registerMessageRoutes(
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { content, chat_id: chatId } = parsed.data;
|
||||
const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
|
||||
parsed.data;
|
||||
const isExternal = provider && provider !== 'boocode';
|
||||
|
||||
// Validate session exists
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
// Validate chat belongs to session and is open
|
||||
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
const resolved = await resolveChatId(sql, sessionId, pane_id);
|
||||
if (!resolved) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
return { error: 'pane not found' };
|
||||
}
|
||||
|
||||
// Reject if inference is already running on this chat
|
||||
if (inference.hasActive(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'inference already running on this chat' };
|
||||
let chatId = resolved;
|
||||
if (explicitChatId) {
|
||||
const chatRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
chatId = explicitChatId;
|
||||
}
|
||||
|
||||
// Create user message + streaming assistant row in a transaction
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
});
|
||||
if (!isExternal) {
|
||||
// Reject if inference is already running on this chat
|
||||
if (inference.hasActive(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'inference already running on this chat' };
|
||||
}
|
||||
}
|
||||
|
||||
// Publish user message frames so WS subscribers see it immediately
|
||||
// Create user message
|
||||
const [userMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
|
||||
// Publish user message frames
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: result.user_message_id,
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: result.user_message_id,
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: result.user_message_id,
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
} as unknown as WsFrame);
|
||||
|
||||
// Enqueue inference — the runner will stream assistant deltas via broker
|
||||
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||
if (isExternal) {
|
||||
// External provider: create a task for the dispatcher
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||
}
|
||||
|
||||
// Native provider: create streaming assistant row + enqueue inference
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
17
apps/coder/src/routes/providers.ts
Normal file
17
apps/coder/src/routes/providers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||
const cwd = req.query.cwd;
|
||||
return getProviderSnapshot(sql, config, cwd);
|
||||
});
|
||||
|
||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||
clearProviderSnapshotCache();
|
||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||
return { refreshed: entries.length };
|
||||
});
|
||||
}
|
||||
93
apps/coder/src/routes/skills.ts
Normal file
93
apps/coder/src/routes/skills.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { getSkillBody } from '@boocode/server/skills';
|
||||
import {
|
||||
buildSkillInvokeSyntheticFrames,
|
||||
buildSkillInvokeUserFrames,
|
||||
DEFAULT_SKILL_USER_MESSAGE,
|
||||
runSkillInvokeTransaction,
|
||||
} from '@boocode/server/skill-invoke';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const SkillInvokeBody = z.object({
|
||||
pane_id: z.string().min(1).max(200),
|
||||
skill_name: z.string().min(1),
|
||||
user_message: z.string().max(64_000).nullable().optional(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
export function registerSkillRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
inference: InferenceApi,
|
||||
): void {
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/skill_invoke',
|
||||
async (req, reply) => {
|
||||
const parsed = SkillInvokeBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { pane_id, skill_name } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
const chatId = await resolveChatId(sql, sessionId, pane_id);
|
||||
if (!chatId) {
|
||||
reply.code(404);
|
||||
return { error: 'pane not found' };
|
||||
}
|
||||
|
||||
if (inference.hasActive(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'inference already running on this chat' };
|
||||
}
|
||||
|
||||
const userText = parsed.data.user_message?.trim()
|
||||
? parsed.data.user_message
|
||||
: DEFAULT_SKILL_USER_MESSAGE;
|
||||
|
||||
const body = await getSkillBody(skill_name);
|
||||
if (body === null) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||
}
|
||||
|
||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||
sessionId,
|
||||
chatId,
|
||||
skillName: skill_name,
|
||||
skillBody: body,
|
||||
userText,
|
||||
});
|
||||
|
||||
for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) {
|
||||
broker.publishFrame(sessionId, frame as WsFrame);
|
||||
}
|
||||
for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) {
|
||||
broker.publishFrame(sessionId, frame as WsFrame);
|
||||
}
|
||||
|
||||
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function registerWebSocket(
|
||||
|
||||
// Send snapshot of existing messages so client can hydrate
|
||||
const messages = await sql<Record<string, unknown>[]>`
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||
summary, tail_start_id, compacted_at
|
||||
FROM messages_with_parts
|
||||
|
||||
@@ -61,3 +61,13 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
|
||||
-- Human inbox: tasks needing attention
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
|
||||
-- v2.1.0: provider picker — extend available_agents with model discovery.
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||
|
||||
-- v2.2.0: Paseo-style session config on tasks.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||
|
||||
154
apps/coder/src/services/__tests__/acp-derive.test.ts
Normal file
154
apps/coder/src/services/__tests__/acp-derive.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { SessionConfigOption } from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
deriveModesFromACP,
|
||||
deriveModelDefinitionsFromACP,
|
||||
findThoughtLevelConfigId,
|
||||
} from '../acp-derive.js';
|
||||
|
||||
describe('deriveModesFromACP', () => {
|
||||
it('prefers modeState.availableModes when present', () => {
|
||||
const { modes, currentModeId } = deriveModesFromACP(
|
||||
[{ id: 'fallback', label: 'Fallback' }],
|
||||
{
|
||||
currentModeId: 'plan',
|
||||
availableModes: [
|
||||
{ id: 'plan', name: 'Plan', description: 'Read-only planning' },
|
||||
{ id: 'code', name: 'Code' },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(modes).toEqual([
|
||||
{ id: 'plan', label: 'Plan', description: 'Read-only planning' },
|
||||
{ id: 'code', label: 'Code', description: undefined },
|
||||
]);
|
||||
expect(currentModeId).toBe('plan');
|
||||
});
|
||||
|
||||
it('falls back to configOptions mode select', () => {
|
||||
const configOptions: SessionConfigOption[] = [
|
||||
{
|
||||
type: 'select',
|
||||
id: 'mode',
|
||||
category: 'mode',
|
||||
currentValue: 'auto',
|
||||
options: [
|
||||
{ value: 'auto', name: 'Auto' },
|
||||
{ value: 'manual', name: 'Manual', description: 'Ask first' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { modes, currentModeId } = deriveModesFromACP([], null, configOptions);
|
||||
|
||||
expect(modes).toEqual([
|
||||
{ id: 'auto', label: 'Auto', description: undefined },
|
||||
{ id: 'manual', label: 'Manual', description: 'Ask first' },
|
||||
]);
|
||||
expect(currentModeId).toBe('auto');
|
||||
});
|
||||
|
||||
it('uses static fallback when no ACP mode data', () => {
|
||||
const fallback = [{ id: 'default', label: 'Default' }];
|
||||
const { modes, currentModeId } = deriveModesFromACP(fallback, null, null);
|
||||
|
||||
expect(modes).toEqual(fallback);
|
||||
expect(currentModeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveModelDefinitionsFromACP', () => {
|
||||
it('maps availableModels with thought_level options', () => {
|
||||
const configOptions: SessionConfigOption[] = [
|
||||
{
|
||||
type: 'select',
|
||||
id: 'thought',
|
||||
category: 'thought_level',
|
||||
currentValue: 'medium',
|
||||
options: [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'medium', name: 'Medium' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const models = deriveModelDefinitionsFromACP(
|
||||
{
|
||||
currentModelId: 'gpt-4',
|
||||
availableModels: [
|
||||
{ modelId: 'gpt-4', name: 'GPT-4' },
|
||||
{ modelId: 'gpt-4-mini', name: 'Mini', description: 'Cheaper' },
|
||||
],
|
||||
},
|
||||
configOptions,
|
||||
);
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
id: 'gpt-4',
|
||||
label: 'GPT-4',
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: 'low', label: 'Low', isDefault: false },
|
||||
{ id: 'medium', label: 'Medium', isDefault: true },
|
||||
],
|
||||
defaultThinkingOptionId: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'gpt-4-mini',
|
||||
label: 'Mini',
|
||||
description: 'Cheaper',
|
||||
isDefault: false,
|
||||
thinkingOptions: [
|
||||
{ id: 'low', label: 'Low', isDefault: false },
|
||||
{ id: 'medium', label: 'Medium', isDefault: true },
|
||||
],
|
||||
defaultThinkingOptionId: 'medium',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to model select config when no availableModels', () => {
|
||||
const configOptions: SessionConfigOption[] = [
|
||||
{
|
||||
type: 'select',
|
||||
id: 'model',
|
||||
category: 'model',
|
||||
currentValue: 'sonnet',
|
||||
options: [
|
||||
{ value: 'sonnet', name: 'Sonnet' },
|
||||
{ value: 'opus', name: 'Opus' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const models = deriveModelDefinitionsFromACP(null, configOptions);
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'sonnet', label: 'Sonnet', isDefault: true, defaultThinkingOptionId: undefined },
|
||||
{ id: 'opus', label: 'Opus', isDefault: false, defaultThinkingOptionId: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findThoughtLevelConfigId', () => {
|
||||
it('returns thought_level select id', () => {
|
||||
const configOptions: SessionConfigOption[] = [
|
||||
{
|
||||
type: 'select',
|
||||
id: 'effort',
|
||||
category: 'thought_level',
|
||||
currentValue: 'high',
|
||||
options: [{ value: 'high', name: 'High' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findThoughtLevelConfigId(configOptions)).toBe('effort');
|
||||
});
|
||||
|
||||
it('returns null when missing', () => {
|
||||
expect(findThoughtLevelConfigId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
66
apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts
Normal file
66
apps/coder/src/services/__tests__/acp-tool-snapshot.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergeToolSnapshot,
|
||||
mapToolLifecycleStatus,
|
||||
snapshotToWireToolCall,
|
||||
synthesizeCanceledSnapshots,
|
||||
} from '../acp-tool-snapshot.js';
|
||||
|
||||
describe('mergeToolSnapshot', () => {
|
||||
it('preserves stable toolCallId across updates', () => {
|
||||
const first = mergeToolSnapshot('tc-1', {
|
||||
toolCallId: 'tc-1',
|
||||
title: 'Read file',
|
||||
kind: 'read',
|
||||
status: 'in_progress',
|
||||
rawInput: { path: 'foo.ts' },
|
||||
});
|
||||
const merged = mergeToolSnapshot(
|
||||
'tc-1',
|
||||
{
|
||||
toolCallId: 'tc-1',
|
||||
title: 'Read file',
|
||||
status: 'completed',
|
||||
rawOutput: { content: 'hello' },
|
||||
},
|
||||
first,
|
||||
);
|
||||
expect(merged.toolCallId).toBe('tc-1');
|
||||
expect(merged.rawInput).toEqual({ path: 'foo.ts' });
|
||||
expect(merged.status).toBe('completed');
|
||||
expect(merged.rawOutput).toEqual({ content: 'hello' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotToWireToolCall', () => {
|
||||
it('embeds ACP lifecycle meta for UI merge', () => {
|
||||
const wire = snapshotToWireToolCall({
|
||||
toolCallId: 'tc-42',
|
||||
title: 'Edit',
|
||||
kind: 'edit',
|
||||
status: 'completed',
|
||||
rawInput: { path: 'a.ts' },
|
||||
rawOutput: 'ok',
|
||||
});
|
||||
expect(wire.id).toBe('tc-42');
|
||||
expect(wire.name).toBe('edit');
|
||||
expect(wire.args._acp).toMatchObject({ status: 'completed', title: 'Edit', output: 'ok' });
|
||||
});
|
||||
|
||||
it('maps synthesized cancel to canceled lifecycle', () => {
|
||||
const [canceled] = synthesizeCanceledSnapshots([
|
||||
{ toolCallId: 'tc-1', title: 'Run', status: 'in_progress' },
|
||||
]);
|
||||
const wire = snapshotToWireToolCall(canceled!);
|
||||
expect(wire.args._acp).toMatchObject({ status: 'canceled' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToolLifecycleStatus', () => {
|
||||
it('maps ACP statuses to UI lifecycle', () => {
|
||||
expect(mapToolLifecycleStatus('completed')).toBe('completed');
|
||||
expect(mapToolLifecycleStatus('failed')).toBe('failed');
|
||||
expect(mapToolLifecycleStatus('in_progress')).toBe('running');
|
||||
expect(mapToolLifecycleStatus(undefined, 'canceled')).toBe('canceled');
|
||||
});
|
||||
});
|
||||
47
apps/coder/src/services/__tests__/cursor-models.test.ts
Normal file
47
apps/coder/src/services/__tests__/cursor-models.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCursorAgentModelsOutput } from '../cursor-models.js';
|
||||
|
||||
describe('parseCursorAgentModelsOutput', () => {
|
||||
it('parses cursor-agent models output with default marker', () => {
|
||||
const output = `
|
||||
Available models
|
||||
claude-4-sonnet - Claude 4 Sonnet (default)
|
||||
gpt-4.1 - GPT-4.1
|
||||
Tip: use cursor-agent models for full list
|
||||
`.trim();
|
||||
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'claude-4-sonnet', label: 'Claude 4 Sonnet', isDefault: true },
|
||||
{ id: 'gpt-4.1', label: 'GPT-4.1', isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses current marker when no default', () => {
|
||||
const output = `
|
||||
model-a - Model A (current)
|
||||
model-b - Model B
|
||||
`.trim();
|
||||
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models.find((m) => m.id === 'model-a')?.isDefault).toBe(true);
|
||||
expect(models.find((m) => m.id === 'model-b')?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to first model when no markers', () => {
|
||||
const output = 'alpha - Alpha\nbeta - Beta';
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models[0]?.isDefault).toBe(true);
|
||||
expect(models[1]?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('skips malformed lines', () => {
|
||||
const output = 'no-separator\nvalid - Valid';
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models).toEqual([{ id: 'valid', label: 'Valid', isDefault: true }]);
|
||||
});
|
||||
});
|
||||
26
apps/coder/src/services/__tests__/provider-commands.test.ts
Normal file
26
apps/coder/src/services/__tests__/provider-commands.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provider-commands.js';
|
||||
|
||||
describe('provider-commands', () => {
|
||||
it('defines commands for every external harness', () => {
|
||||
for (const name of ['claude', 'opencode', 'cursor', 'goose', 'qwen', 'copilot']) {
|
||||
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('boocode uses frontend skills — empty manifest', () => {
|
||||
expect(getManifestCommands('boocode')).toEqual([]);
|
||||
expect(PROVIDER_COMMANDS.boocode).toEqual([]);
|
||||
});
|
||||
|
||||
it('mergeCommands dedupes by name with later override', () => {
|
||||
const merged = mergeCommands(
|
||||
[{ name: 'help', description: 'a' }],
|
||||
[{ name: 'help', description: 'b' }, { name: 'clear' }],
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ name: 'clear' },
|
||||
{ name: 'help', description: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
168
apps/coder/src/services/__tests__/provider-snapshot.test.ts
Normal file
168
apps/coder/src/services/__tests__/provider-snapshot.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
mergeModels,
|
||||
prefixLlamaSwapModels,
|
||||
clearProviderSnapshotCache,
|
||||
getProviderSnapshot,
|
||||
} from '../provider-snapshot.js';
|
||||
|
||||
vi.mock('../acp-probe.js', () => ({
|
||||
probeAcpProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
import { probeAcpProvider } from '../acp-probe.js';
|
||||
|
||||
const mockProbe = vi.mocked(probeAcpProvider);
|
||||
|
||||
function mockSql(agents: Array<{
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
supports_acp: boolean;
|
||||
models: Array<{ id: string; label: string }> | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
}>) {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const query = strings.join('');
|
||||
if (query.includes('FROM available_agents')) {
|
||||
return Promise.resolve(agents);
|
||||
}
|
||||
if (query.includes('UPDATE available_agents')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}) as unknown as import('../db.js').Sql;
|
||||
}
|
||||
|
||||
const config = {
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
} as import('../config.js').Config;
|
||||
|
||||
describe('prefixLlamaSwapModels', () => {
|
||||
it('prefixes bare ids', () => {
|
||||
expect(prefixLlamaSwapModels([{ id: 'qwen3', label: 'qwen3' }])).toEqual([
|
||||
{ id: 'llama-swap/qwen3', label: 'qwen3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves already-prefixed ids unchanged', () => {
|
||||
expect(prefixLlamaSwapModels([{ id: 'llama-swap/qwen3', label: 'qwen3' }])).toEqual([
|
||||
{ id: 'llama-swap/qwen3', label: 'qwen3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeModels', () => {
|
||||
it('dedupes by id preserving first occurrence', () => {
|
||||
const merged = mergeModels(
|
||||
[{ id: 'a', label: 'A' }],
|
||||
[{ id: 'a', label: 'A2' }, { id: 'b', label: 'B' }],
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ id: 'a', label: 'A' },
|
||||
{ id: 'b', label: 'B' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges opencode ACP models with prefixed llama-swap models', async () => {
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
|
||||
modes: [{ id: 'build', label: 'Build' }],
|
||||
defaultModeId: 'build',
|
||||
commands: [{ name: 'custom', description: 'From ACP probe' }],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'opencode',
|
||||
install_path: '/usr/bin/opencode',
|
||||
supports_acp: true,
|
||||
models: null,
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
},
|
||||
]);
|
||||
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const opencode = entries.find((e) => e.name === 'opencode');
|
||||
|
||||
expect(opencode?.models.map((m) => m.id)).toEqual([
|
||||
'opencode/big-pickle',
|
||||
'llama-swap/local-model',
|
||||
'llama-swap/existing',
|
||||
]);
|
||||
expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('combines qwen-shaped probe and settings model lists via mergeModels', () => {
|
||||
const merged = mergeModels(
|
||||
[{ id: 'qwen-probed', label: 'Qwen Probed' }],
|
||||
[{ id: 'from-settings', label: 'from-settings' }],
|
||||
);
|
||||
expect(merged.map((m) => m.id)).toEqual(['qwen-probed', 'from-settings']);
|
||||
});
|
||||
|
||||
it('returns cached entries on second call within TTL', async () => {
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'm1', label: 'M1' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: null,
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
},
|
||||
]);
|
||||
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||
|
||||
expect(mockProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('attaches claude thinking options', async () => {
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'claude',
|
||||
install_path: '/usr/bin/claude',
|
||||
supports_acp: false,
|
||||
models: [{ id: 'claude-sonnet', label: 'Sonnet' }],
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
},
|
||||
]);
|
||||
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const claude = entries.find((e) => e.name === 'claude');
|
||||
|
||||
expect(claude?.models[0]?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
});
|
||||
35
apps/coder/src/services/acp-client-fs.ts
Normal file
35
apps/coder/src/services/acp-client-fs.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||
export async function readWorktreeTextFile(
|
||||
worktreePath: string,
|
||||
filePath: string,
|
||||
line?: number | null,
|
||||
limit?: number | null,
|
||||
): Promise<string> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const raw = await fs.readFile(absolute, 'utf8');
|
||||
if (!line && !limit) return raw;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const start = Math.max((line ?? 1) - 1, 0);
|
||||
const end = limit ? start + limit : undefined;
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
/** Write a file inside the worktree (creates parent dirs). */
|
||||
export async function writeWorktreeTextFile(
|
||||
worktreePath: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, content, 'utf8');
|
||||
}
|
||||
128
apps/coder/src/services/acp-derive.ts
Normal file
128
apps/coder/src/services/acp-derive.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* ACP model/mode derivation — adapted from Paseo acp-agent.ts.
|
||||
*/
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { ProviderMode, ProviderModel, ThinkingOption } from './provider-types.js';
|
||||
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: 'select' }>;
|
||||
|
||||
interface SelectConfigChoice {
|
||||
value: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
function findSelectConfigOption({
|
||||
configOptions,
|
||||
category,
|
||||
id,
|
||||
}: {
|
||||
configOptions: SessionConfigOption[] | null | undefined;
|
||||
category: string;
|
||||
id?: string;
|
||||
}): SelectConfigOption | null {
|
||||
const option = configOptions?.find(
|
||||
(entry): entry is SelectConfigOption =>
|
||||
entry.type === 'select' && entry.category === category && (!id || entry.id === id),
|
||||
);
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
function flattenSelectOptions(options: SelectConfigOption['options']): SelectConfigChoice[] {
|
||||
const flattened: SelectConfigChoice[] = [];
|
||||
for (const option of options) {
|
||||
if ('value' in option) {
|
||||
flattened.push(option);
|
||||
continue;
|
||||
}
|
||||
for (const groupOption of option.options) {
|
||||
flattened.push({ ...groupOption, group: option.group });
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function deriveSelectorOptions(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
category: string,
|
||||
): ThinkingOption[] {
|
||||
const option = findSelectConfigOption({ configOptions, category });
|
||||
if (!option) return [];
|
||||
|
||||
return flattenSelectOptions(option.options).map((value) => ({
|
||||
id: value.value,
|
||||
label: value.name,
|
||||
isDefault: value.value === option.currentValue,
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveModesFromACP(
|
||||
fallbackModes: ProviderMode[],
|
||||
modeState?: SessionModeState | null,
|
||||
configOptions?: SessionConfigOption[] | null,
|
||||
): { modes: ProviderMode[]; currentModeId: string | null } {
|
||||
if (modeState?.availableModes?.length) {
|
||||
return {
|
||||
modes: modeState.availableModes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.name,
|
||||
description: mode.description ?? undefined,
|
||||
})),
|
||||
currentModeId: modeState.currentModeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const modeOption = findSelectConfigOption({ configOptions, category: 'mode' });
|
||||
if (modeOption) {
|
||||
const flatOptions = flattenSelectOptions(modeOption.options);
|
||||
return {
|
||||
modes: flatOptions.map((option) => ({
|
||||
id: option.value,
|
||||
label: option.name,
|
||||
description: option.description ?? undefined,
|
||||
})),
|
||||
currentModeId: modeOption.currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
return { modes: fallbackModes, currentModeId: null };
|
||||
}
|
||||
|
||||
export function deriveModelDefinitionsFromACP(
|
||||
models: SessionModelState | null | undefined,
|
||||
configOptions?: SessionConfigOption[] | null,
|
||||
): ProviderModel[] {
|
||||
const thinkingOptions = deriveSelectorOptions(configOptions, 'thought_level');
|
||||
const defaultThinkingOptionId = thinkingOptions.find((o) => o.isDefault)?.id;
|
||||
|
||||
if (models?.availableModels?.length) {
|
||||
return models.availableModels.map((model) => ({
|
||||
id: model.modelId,
|
||||
label: model.name,
|
||||
description: model.description ?? undefined,
|
||||
isDefault: model.modelId === models.currentModelId,
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const modelOptions = deriveSelectorOptions(configOptions, 'model');
|
||||
return modelOptions.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
isDefault: option.isDefault,
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
defaultThinkingOptionId: defaultThinkingOptionId ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function findThoughtLevelConfigId(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
): string | null {
|
||||
return findSelectConfigOption({ configOptions, category: 'thought_level' })?.id ?? null;
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH.
|
||||
* ACP dispatch — runs ACP-capable agents directly on the host.
|
||||
*
|
||||
* Uses the @agentclientprotocol/sdk to establish a structured JSON-RPC session
|
||||
* with the agent subprocess. The SSH tunnel provides stdio transport.
|
||||
*
|
||||
* Flow:
|
||||
* 1. SSH to host, start `opencode acp` (or `goose acp`) in the worktree
|
||||
* 2. Wrap SSH 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 { sshSpawn } from './ssh.js';
|
||||
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,211 +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;
|
||||
}
|
||||
|
||||
/** Map agent name to the ACP command it exposes. */
|
||||
function acpCommand(agent: string): string | null {
|
||||
switch (agent) {
|
||||
case 'opencode':
|
||||
return 'opencode acp';
|
||||
case 'goose':
|
||||
return 'goose acp';
|
||||
default:
|
||||
return null;
|
||||
async function applySessionOverrides(
|
||||
connection: ConnectionType,
|
||||
acpSessionId: string,
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
opts: Pick<AcpDispatchOpts, 'model' | 'modeId' | 'thinkingOptionId' | 'log'>,
|
||||
): Promise<void> {
|
||||
const { model, modeId, thinkingOptionId, log } = opts;
|
||||
|
||||
if (modeId) {
|
||||
try {
|
||||
await connection.setSessionMode({ sessionId: acpSessionId, modeId });
|
||||
} catch (err) {
|
||||
log.warn({ modeId, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionMode failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (model) {
|
||||
try {
|
||||
await connection.unstable_setSessionModel({ sessionId: acpSessionId, modelId: model });
|
||||
} catch (err) {
|
||||
log.warn({ model, err: err instanceof Error ? err.message : String(err) }, 'acp-dispatch: setSessionModel failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingOptionId) {
|
||||
const configId = findThoughtLevelConfigId(configOptions);
|
||||
if (configId) {
|
||||
try {
|
||||
await connection.setSessionConfigOption({
|
||||
sessionId: acpSessionId,
|
||||
configId,
|
||||
value: thinkingOptionId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ thinkingOptionId, err: err instanceof Error ? err.message : String(err) },
|
||||
'acp-dispatch: setSessionConfigOption failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on('data', (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
nodeStream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
||||
(nodeStream as Readable).destroy();
|
||||
class AcpStreamContext {
|
||||
readonly textChunks: string[] = [];
|
||||
readonly reasoningChunks: string[] = [];
|
||||
readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
|
||||
private aborted = false;
|
||||
|
||||
constructor(
|
||||
private readonly opts: Pick<
|
||||
AcpDispatchOpts,
|
||||
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
|
||||
>,
|
||||
private readonly worktreePath: string,
|
||||
) {}
|
||||
|
||||
get reasoningText(): string {
|
||||
return this.reasoningChunks.join('');
|
||||
}
|
||||
|
||||
get output(): string {
|
||||
return this.textChunks.join('');
|
||||
}
|
||||
|
||||
get snapshots(): AcpToolSnapshot[] {
|
||||
return [...this.toolSnapshots.values()];
|
||||
}
|
||||
|
||||
markAborted(): void {
|
||||
this.aborted = true;
|
||||
for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
|
||||
this.toolSnapshots.set(snap.toolCallId, snap);
|
||||
this.publishToolSnapshot(snap);
|
||||
}
|
||||
}
|
||||
|
||||
private canStream(): boolean {
|
||||
return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
|
||||
}
|
||||
|
||||
private publishToolSnapshot(snapshot: AcpToolSnapshot): void {
|
||||
if (!this.canStream()) return;
|
||||
const wire = snapshotToWireToolCall(snapshot);
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'tool_call',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
tool_call: wire,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
handleToolUpdate(toolCallId: string, update: Parameters<typeof mergeToolSnapshot>[1]): void {
|
||||
const previous = this.toolSnapshots.get(toolCallId);
|
||||
const snapshot = mergeToolSnapshot(toolCallId, update, previous);
|
||||
this.toolSnapshots.set(toolCallId, snapshot);
|
||||
this.publishToolSnapshot(snapshot);
|
||||
}
|
||||
|
||||
async handleSessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
switch (update.sessionUpdate) {
|
||||
case 'agent_message_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.textChunks.push(text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
case 'agent_thought_chunk': {
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
const text = (content as { text: string }).text;
|
||||
this.reasoningChunks.push(text);
|
||||
if (this.canStream()) {
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId!, {
|
||||
type: 'reasoning_delta',
|
||||
message_id: this.opts.messageId!,
|
||||
chat_id: this.opts.chatId!,
|
||||
content: text,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'tool_call_update':
|
||||
this.handleToolUpdate(update.toolCallId, update);
|
||||
break;
|
||||
case 'available_commands_update': {
|
||||
const commands = update.availableCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? undefined,
|
||||
}));
|
||||
if (this.opts.taskId && commands.length > 0) {
|
||||
mergeTaskCommands(this.opts.taskId, commands);
|
||||
if (this.canStream() && this.opts.sessionId) {
|
||||
const all = getTaskCommands(this.opts.taskId) ?? commands;
|
||||
this.opts.broker!.publishFrame(this.opts.sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: this.opts.taskId,
|
||||
session_id: this.opts.sessionId,
|
||||
commands: all,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
|
||||
return {
|
||||
sessionUpdate: (params) => this.handleSessionUpdate(params),
|
||||
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
||||
if (taskId && sessionId) {
|
||||
return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
|
||||
}
|
||||
const firstOption = params.options[0];
|
||||
if (firstOption) {
|
||||
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
|
||||
}
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
|
||||
const content = await readWorktreeTextFile(
|
||||
this.worktreePath,
|
||||
params.path,
|
||||
params.line,
|
||||
params.limit,
|
||||
);
|
||||
return { content };
|
||||
},
|
||||
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
|
||||
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
|
||||
return {};
|
||||
},
|
||||
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
if (ok) resolve();
|
||||
else (nodeStream as Writable).once('drain', resolve);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => {
|
||||
(nodeStream as Writable).end(resolve);
|
||||
});
|
||||
},
|
||||
abort() {
|
||||
(nodeStream as Writable).destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to an ACP-capable agent via SSH.
|
||||
*
|
||||
* Opens a structured ACP session, sends the task as a prompt, and collects
|
||||
* all session updates. Returns the collected output and tool calls.
|
||||
*/
|
||||
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
|
||||
const { agent, task, worktreePath, signal, log } = opts;
|
||||
const {
|
||||
agent,
|
||||
task,
|
||||
worktreePath,
|
||||
installPath,
|
||||
signal,
|
||||
log,
|
||||
taskId,
|
||||
modeId,
|
||||
sessionId,
|
||||
chatId,
|
||||
messageId,
|
||||
broker,
|
||||
} = opts;
|
||||
|
||||
const cmd = acpCommand(agent);
|
||||
if (!cmd) {
|
||||
const args = resolveAcpSpawnArgs(agent);
|
||||
if (!args) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: `Agent '${agent}' does not support ACP.`,
|
||||
toolCalls: [],
|
||||
toolSnapshots: [],
|
||||
reasoningText: '',
|
||||
stopReason: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
// Spawn SSH with the ACP command running in the worktree
|
||||
const escapedPath = worktreePath.replace(/'/g, "'\\''");
|
||||
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
|
||||
const binary = installPath ?? agent;
|
||||
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(binary, args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
|
||||
const child = sshSpawn(fullCommand);
|
||||
const streamCtx = new AcpStreamContext(
|
||||
{ broker, sessionId, chatId, messageId, taskId },
|
||||
worktreePath,
|
||||
);
|
||||
|
||||
// Wire up abort
|
||||
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<void> {
|
||||
const update = params.update;
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
// ContentChunk with content: ContentBlock
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
textChunks.push((content as { text: string }).text);
|
||||
}
|
||||
} else if (update.sessionUpdate === 'tool_call') {
|
||||
toolCalls.push({
|
||||
title: update.title,
|
||||
input: update.rawInput,
|
||||
});
|
||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
||||
const last = toolCalls[toolCalls.length - 1];
|
||||
if (last && update.rawOutput !== undefined) {
|
||||
last.output = update.rawOutput;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Select the first available option to auto-approve
|
||||
const firstOption = params.options[0];
|
||||
if (firstOption) {
|
||||
return {
|
||||
outcome: { outcome: 'selected', optionId: firstOption.optionId },
|
||||
};
|
||||
}
|
||||
// No options available — cancel
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
|
||||
// File system operations — let the agent handle them directly in the worktree
|
||||
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return { content: '' };
|
||||
},
|
||||
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return {};
|
||||
},
|
||||
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
}),
|
||||
() => 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) {
|
||||
@@ -255,14 +367,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: message,
|
||||
toolCalls: [],
|
||||
toolSnapshots: streamCtx.snapshots,
|
||||
reasoningText: streamCtx.reasoningText,
|
||||
stopReason: 'error',
|
||||
};
|
||||
} finally {
|
||||
if (signal) signal.removeEventListener('abort', cleanup);
|
||||
cleanup();
|
||||
|
||||
// Wait for child to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on('close', resolve);
|
||||
setTimeout(resolve, 3_000);
|
||||
|
||||
155
apps/coder/src/services/acp-probe.ts
Normal file
155
apps/coder/src/services/acp-probe.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Short-lived ACP probe — opens a session and reads models/modes from the response.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
type Client,
|
||||
type NewSessionResponse,
|
||||
type ReadTextFileRequest,
|
||||
type ReadTextFileResponse,
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { deriveModesFromACP, deriveModelDefinitionsFromACP } from './acp-derive.js';
|
||||
import { getManifestDefaultModeId, getManifestModes } from './provider-manifest.js';
|
||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||
import type { ProviderModel, ProviderMode } from './provider-types.js';
|
||||
import type { AgentCommand } from './agent-commands-cache.js';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface AcpProbeResult {
|
||||
ok: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function parseSessionResponse(session: NewSessionResponse, agent: string): AcpProbeResult {
|
||||
const fallbackModes = getManifestModes(agent);
|
||||
const { modes, currentModeId } = deriveModesFromACP(
|
||||
fallbackModes,
|
||||
session.modes,
|
||||
session.configOptions,
|
||||
);
|
||||
const models = deriveModelDefinitionsFromACP(session.models, session.configOptions);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
models,
|
||||
modes,
|
||||
defaultModeId: currentModeId ?? getManifestDefaultModeId(agent),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeAcpProvider(
|
||||
agent: string,
|
||||
installPath: string,
|
||||
cwd: string,
|
||||
): Promise<AcpProbeResult> {
|
||||
const args = resolveAcpSpawnArgs(agent);
|
||||
if (!args) {
|
||||
return {
|
||||
ok: false,
|
||||
models: [],
|
||||
modes: getManifestModes(agent),
|
||||
defaultModeId: getManifestDefaultModeId(agent),
|
||||
commands: [],
|
||||
error: 'no ACP spawn args',
|
||||
};
|
||||
}
|
||||
|
||||
const child = spawn(installPath, args, {
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let killed = false;
|
||||
const kill = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 2_000);
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(kill, PROBE_TIMEOUT_MS);
|
||||
|
||||
const probedCommands: AgentCommand[] = [];
|
||||
|
||||
try {
|
||||
const stream = createAcpNdJsonStream(child);
|
||||
|
||||
const connection = new ClientSideConnection(
|
||||
(_agentInterface): Client => ({
|
||||
async sessionUpdate(params) {
|
||||
const update = params.update;
|
||||
if (update.sessionUpdate === 'available_commands_update') {
|
||||
for (const cmd of update.availableCommands) {
|
||||
probedCommands.push({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const first = params.options[0];
|
||||
if (first) {
|
||||
return { outcome: { outcome: 'selected', optionId: first.optionId } };
|
||||
}
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return { content: '' };
|
||||
},
|
||||
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return {};
|
||||
},
|
||||
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
}),
|
||||
stream,
|
||||
);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
clientInfo: { name: 'boocoder-probe', version: '2.2.0' },
|
||||
clientCapabilities: {},
|
||||
});
|
||||
|
||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||
const result = parseSessionResponse(session, agent);
|
||||
result.commands = probedCommands;
|
||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
models: [],
|
||||
modes: getManifestModes(agent),
|
||||
defaultModeId: getManifestDefaultModeId(agent),
|
||||
commands: probedCommands,
|
||||
error: message,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
kill();
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on('close', resolve);
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
29
apps/coder/src/services/acp-spawn.ts
Normal file
29
apps/coder/src/services/acp-spawn.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
|
||||
*/
|
||||
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
switch (agent) {
|
||||
case 'opencode':
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
case 'cursor':
|
||||
return ['acp'];
|
||||
case 'copilot':
|
||||
return ['--acp'];
|
||||
case 'qwen':
|
||||
return ['--acp'];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||
switch (agent) {
|
||||
case 'cursor':
|
||||
return ['cursor-agent', 'agent'];
|
||||
case 'copilot':
|
||||
return ['copilot'];
|
||||
default:
|
||||
return [agent];
|
||||
}
|
||||
}
|
||||
44
apps/coder/src/services/acp-stream.ts
Normal file
44
apps/coder/src/services/acp-stream.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { ndJsonStream } from '@agentclientprotocol/sdk';
|
||||
|
||||
export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
||||
nodeStream.on('end', () => controller.close());
|
||||
nodeStream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
||||
(nodeStream as Readable).destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
if (ok) resolve();
|
||||
else (nodeStream as Writable).once('drain', resolve);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => {
|
||||
(nodeStream as Writable).end(resolve);
|
||||
});
|
||||
},
|
||||
abort() {
|
||||
(nodeStream as Writable).destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createAcpNdJsonStream(child: ChildProcess) {
|
||||
return ndJsonStream(nodeWritableToWeb(child.stdin!), nodeReadableToWeb(child.stdout!));
|
||||
}
|
||||
120
apps/coder/src/services/acp-tool-snapshot.ts
Normal file
120
apps/coder/src/services/acp-tool-snapshot.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* ACP tool snapshot merge + wire mapping — lifted from Paseo acp-agent.ts patterns.
|
||||
* Stable toolCallId, merge on tool_call_update, status lifecycle for UI + DB.
|
||||
*/
|
||||
import type { ToolCall, ToolCallUpdate, ToolCallStatus, ToolKind } from '@agentclientprotocol/sdk';
|
||||
|
||||
export type AcpToolLifecycleStatus = 'running' | 'completed' | 'failed' | 'canceled';
|
||||
|
||||
export interface AcpToolSnapshot {
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
kind?: ToolKind | null;
|
||||
status?: ToolCallStatus | null;
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
}
|
||||
|
||||
export interface AcpWireMeta {
|
||||
status: AcpToolLifecycleStatus;
|
||||
kind?: string | null;
|
||||
title?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function coalesceDefined<T>(next: T | null | undefined, previous: T | null | undefined, fallback: T | null): T | null {
|
||||
if (next !== undefined && next !== null) return next;
|
||||
if (previous !== undefined && previous !== null) return previous;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function mergeToolSnapshot(
|
||||
toolCallId: string,
|
||||
update: ToolCall | ToolCallUpdate,
|
||||
previous?: AcpToolSnapshot,
|
||||
): AcpToolSnapshot {
|
||||
return {
|
||||
toolCallId,
|
||||
title: update.title ?? previous?.title ?? toolCallId,
|
||||
kind: update.kind ?? previous?.kind ?? null,
|
||||
status: update.status ?? previous?.status ?? null,
|
||||
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
|
||||
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapToolLifecycleStatus(
|
||||
status: ToolCallStatus | null | undefined,
|
||||
rawOutput?: unknown,
|
||||
): AcpToolLifecycleStatus {
|
||||
if (rawOutput === 'canceled') return 'canceled';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
return 'failed';
|
||||
case 'pending':
|
||||
case 'in_progress':
|
||||
default:
|
||||
return 'running';
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorMessage(rawOutput: unknown): string | undefined {
|
||||
if (typeof rawOutput === 'string' && rawOutput.trim()) return rawOutput;
|
||||
if (rawOutput && typeof rawOutput === 'object' && !Array.isArray(rawOutput)) {
|
||||
const rec = rawOutput as Record<string, unknown>;
|
||||
const msg = rec.message ?? rec.error ?? rec.reason;
|
||||
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function snapshotToWireToolCall(snapshot: AcpToolSnapshot): {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
} {
|
||||
const lifecycle = mapToolLifecycleStatus(snapshot.status, snapshot.rawOutput);
|
||||
const input = asRecord(snapshot.rawInput);
|
||||
const error = lifecycle === 'failed' ? readErrorMessage(snapshot.rawOutput) : undefined;
|
||||
const meta: AcpWireMeta = {
|
||||
status: lifecycle,
|
||||
kind: snapshot.kind ?? null,
|
||||
title: snapshot.title,
|
||||
...(snapshot.rawOutput !== undefined ? { output: snapshot.rawOutput } : {}),
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
return {
|
||||
id: snapshot.toolCallId,
|
||||
name: String(snapshot.kind ?? snapshot.title),
|
||||
args: { ...input, _acp: meta },
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotToPartPayload(snapshot: AcpToolSnapshot): {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
} {
|
||||
const wire = snapshotToWireToolCall(snapshot);
|
||||
return { id: wire.id, name: wire.name, args: wire.args };
|
||||
}
|
||||
|
||||
export function synthesizeCanceledSnapshots(snapshots: Iterable<AcpToolSnapshot>): AcpToolSnapshot[] {
|
||||
const out: AcpToolSnapshot[] = [];
|
||||
for (const snapshot of snapshots) {
|
||||
if (mapToolLifecycleStatus(snapshot.status) === 'running') {
|
||||
out.push({ ...snapshot, status: 'failed', rawOutput: snapshot.rawOutput ?? 'canceled' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
28
apps/coder/src/services/agent-commands-cache.ts
Normal file
28
apps/coder/src/services/agent-commands-cache.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** In-memory cache of ACP available_commands_update per task. */
|
||||
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
import { mergeCommands } from './provider-commands.js';
|
||||
|
||||
export type { AgentCommand };
|
||||
|
||||
const commandsByTask = new Map<string, AgentCommand[]>();
|
||||
|
||||
export function setTaskCommands(taskId: string, commands: AgentCommand[]): void {
|
||||
if (commands.length === 0) return;
|
||||
commandsByTask.set(taskId, commands);
|
||||
}
|
||||
|
||||
/** Merge by command name; later lists override earlier entries. */
|
||||
export function mergeTaskCommands(taskId: string, commands: AgentCommand[]): void {
|
||||
if (commands.length === 0) return;
|
||||
const merged = mergeCommands(commandsByTask.get(taskId) ?? [], commands);
|
||||
commandsByTask.set(taskId, merged);
|
||||
}
|
||||
|
||||
export function getTaskCommands(taskId: string): AgentCommand[] | null {
|
||||
return commandsByTask.get(taskId) ?? null;
|
||||
}
|
||||
|
||||
export function clearTaskCommands(taskId: string): void {
|
||||
commandsByTask.delete(taskId);
|
||||
}
|
||||
@@ -1,69 +1,113 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { sshExec } from './ssh.js';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
|
||||
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
|
||||
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [
|
||||
{ name: 'opencode', supportsAcp: true },
|
||||
{ name: 'goose', supportsAcp: true },
|
||||
{ name: 'claude', supportsAcp: false },
|
||||
{ name: 'pi', supportsAcp: false },
|
||||
{ name: 'qwen', supportsAcp: false },
|
||||
];
|
||||
const exec = promisify(execCb);
|
||||
|
||||
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
||||
const candidates = resolveAcpProbeBinaries(agentName);
|
||||
for (const bin of candidates) {
|
||||
try {
|
||||
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
|
||||
const path = stdout.trim();
|
||||
if (path) return path;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectAcpSupport(agentName: string, installPath: string): Promise<boolean> {
|
||||
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
||||
if (transport !== 'acp') return false;
|
||||
|
||||
if (agentName === 'copilot') {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||
return stdout.includes('--acp');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName === 'qwen') {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||
return stdout.includes('--acp');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await exec(`"${installPath}" acp --help`, { timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for available agents on the HOST via SSH.
|
||||
*
|
||||
* The boocoder container can't run agents locally — they live on the host.
|
||||
* We SSH to the host (same mechanism BooTerm uses) and check which agent
|
||||
* binaries are on PATH.
|
||||
* Probe for available agents on the HOST.
|
||||
*/
|
||||
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||
log.info('agent-probe: scanning HOST for known agents via SSH');
|
||||
clearProviderSnapshotCache();
|
||||
log.info('agent-probe: scanning for known agents');
|
||||
|
||||
for (const agent of KNOWN_AGENTS) {
|
||||
for (const agentName of PROBED_AGENT_NAMES) {
|
||||
try {
|
||||
// Check if the agent binary is on the host's PATH
|
||||
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 });
|
||||
const installPath = whichResult.stdout.trim();
|
||||
if (whichResult.exitCode !== 0 || !installPath) continue;
|
||||
const installPath = await resolveInstallPath(agentName);
|
||||
if (!installPath) continue;
|
||||
|
||||
// Get version
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 });
|
||||
if (verResult.exitCode === 0) {
|
||||
version = verResult.stdout.trim().slice(0, 100);
|
||||
}
|
||||
const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
|
||||
version = verOut.trim().slice(0, 100);
|
||||
} catch {
|
||||
// Some agents may not support --version — that's fine
|
||||
/* optional */
|
||||
}
|
||||
|
||||
// For ACP-capable agents, verify ACP mode actually works
|
||||
let supportsAcp = agent.supportsAcp;
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||
let supportsAcp = providerDef?.transport === 'acp';
|
||||
if (supportsAcp) {
|
||||
try {
|
||||
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
|
||||
supportsAcp = acpCheck.exitCode === 0;
|
||||
} catch {
|
||||
supportsAcp = false;
|
||||
}
|
||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||
}
|
||||
|
||||
// UPSERT into available_agents
|
||||
let models: Array<{ id: string; label: string }> = [];
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
}
|
||||
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
const label = providerDef?.label ?? agentName;
|
||||
const transport =
|
||||
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
|
||||
|
||||
await sql`
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
|
||||
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||
VALUES (${agentName}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
install_path = EXCLUDED.install_path,
|
||||
version = EXCLUDED.version,
|
||||
supports_acp = EXCLUDED.supports_acp,
|
||||
last_probed_at = EXCLUDED.last_probed_at
|
||||
last_probed_at = EXCLUDED.last_probed_at,
|
||||
models = EXCLUDED.models,
|
||||
label = EXCLUDED.label,
|
||||
transport = EXCLUDED.transport
|
||||
`;
|
||||
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host');
|
||||
log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
|
||||
} catch (err) {
|
||||
// SSH failed or agent not found — skip silently
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found or SSH failed');
|
||||
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
apps/coder/src/services/agent-turn-persist.ts
Normal file
56
apps/coder/src/services/agent-turn-persist.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||
import { snapshotToPartPayload } from './acp-tool-snapshot.js';
|
||||
|
||||
interface PartInsert {
|
||||
message_id: string;
|
||||
sequence: number;
|
||||
kind: 'reasoning' | 'tool_call';
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
|
||||
if (parts.length === 0) return;
|
||||
await sql`
|
||||
INSERT INTO message_parts ${sql(
|
||||
parts.map((p) => ({
|
||||
message_id: p.message_id,
|
||||
sequence: p.sequence,
|
||||
kind: p.kind,
|
||||
payload: sql.json(p.payload as never),
|
||||
})),
|
||||
'message_id',
|
||||
'sequence',
|
||||
'kind',
|
||||
'payload',
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Persist external-agent reasoning + tool calls into message_parts for reload. */
|
||||
export async function persistExternalAgentTurn(
|
||||
sql: Sql,
|
||||
assistantMessageId: string,
|
||||
snapshots: AcpToolSnapshot[],
|
||||
reasoningText: string,
|
||||
): Promise<void> {
|
||||
const parts: PartInsert[] = [];
|
||||
let seq = 0;
|
||||
if (reasoningText.trim()) {
|
||||
parts.push({
|
||||
message_id: assistantMessageId,
|
||||
sequence: seq++,
|
||||
kind: 'reasoning',
|
||||
payload: { text: reasoningText },
|
||||
});
|
||||
}
|
||||
for (const snapshot of snapshots) {
|
||||
parts.push({
|
||||
message_id: assistantMessageId,
|
||||
sequence: seq++,
|
||||
kind: 'tool_call',
|
||||
payload: snapshotToPartPayload(snapshot),
|
||||
});
|
||||
}
|
||||
await insertParts(sql, parts);
|
||||
}
|
||||
39
apps/coder/src/services/cursor-models.ts
Normal file
39
apps/coder/src/services/cursor-models.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Cursor model list parser — lifted from Paseo cursor-acp-agent.ts
|
||||
*/
|
||||
import type { ProviderModel } from './provider-types.js';
|
||||
|
||||
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
|
||||
|
||||
export function parseCursorAgentModelsOutput(output: string): ProviderModel[] {
|
||||
const parsed = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line !== 'Available models' && !line.startsWith('Tip:'))
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf(' - ');
|
||||
if (separatorIndex <= 0) return null;
|
||||
|
||||
const id = line.slice(0, separatorIndex).trim();
|
||||
const rawLabel = line.slice(separatorIndex + 3).trim();
|
||||
if (!id || !rawLabel) return null;
|
||||
|
||||
let marker: 'default' | 'current' | null = null;
|
||||
if (rawLabel.endsWith(' (default)')) marker = 'default';
|
||||
else if (rawLabel.endsWith(' (current)')) marker = 'current';
|
||||
|
||||
return { id, label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ''), marker };
|
||||
})
|
||||
.filter((m): m is { id: string; label: string; marker: 'default' | 'current' | null } => m !== null);
|
||||
|
||||
const defaultModelId =
|
||||
parsed.find((m) => m.marker === 'default')?.id ??
|
||||
parsed.find((m) => m.marker === 'current')?.id ??
|
||||
parsed[0]?.id;
|
||||
|
||||
return parsed.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
isDefault: model.id === defaultModelId,
|
||||
}));
|
||||
}
|
||||
@@ -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<void> } {
|
||||
const { sql, inference, log, config } = deps;
|
||||
const { sql, inference, broker, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let running = false;
|
||||
let stopping = false;
|
||||
@@ -34,8 +38,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
if (running || stopping) return;
|
||||
|
||||
// Grab one pending task
|
||||
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
|
||||
SELECT id, project_id, input, agent, model
|
||||
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,16 +64,25 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
});
|
||||
}
|
||||
|
||||
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
|
||||
async function runTask(task: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
}): Promise<void> {
|
||||
const taskId = task.id;
|
||||
|
||||
// Determine execution path: if agent is specified AND exists in available_agents → Path B
|
||||
if (task.agent) {
|
||||
const [agentRow] = await sql<{ name: string; supports_acp: boolean }[]>`
|
||||
SELECT name, supports_acp FROM available_agents WHERE name = ${task.agent}
|
||||
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
|
||||
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||
`;
|
||||
if (agentRow) {
|
||||
await runExternalAgent(task, agentRow.supports_acp);
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
return;
|
||||
}
|
||||
// Agent specified but not available — fall through to Path A with a warning
|
||||
@@ -73,7 +95,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
||||
|
||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
|
||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
|
||||
const taskId = task.id;
|
||||
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||
|
||||
@@ -179,8 +201,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||||
|
||||
async function runExternalAgent(
|
||||
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null },
|
||||
task: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
},
|
||||
supportsAcp: boolean,
|
||||
installPath: string | null,
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const agent = task.agent!;
|
||||
@@ -189,14 +221,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
|
||||
|
||||
// Resolve the project's root path
|
||||
const [project] = await sql<{ root_path: string | null }[]>`
|
||||
SELECT root_path FROM projects WHERE id = ${task.project_id}
|
||||
const [project] = await sql<{ path: string | null }[]>`
|
||||
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||
`;
|
||||
const projectPath = project?.root_path;
|
||||
const projectPath = project?.path;
|
||||
if (!projectPath) {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_path — cannot create worktree'
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
return;
|
||||
@@ -213,30 +245,49 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Create session + chat for this task (same as Path A — for output tracking)
|
||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const sessionId = session!.id;
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const chatId = chat!.id;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
if (chats.length === 0) {
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
} else {
|
||||
chatId = chats[0]!.id;
|
||||
}
|
||||
} else {
|
||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = session!.id;
|
||||
|
||||
// Link task to session
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
|
||||
// Create user message for the task input
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
`;
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
}
|
||||
|
||||
if (!task.session_id) {
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
`;
|
||||
}
|
||||
|
||||
// Step 1: Create worktree
|
||||
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
|
||||
@@ -245,42 +296,92 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
// Step 2: Dispatch to agent
|
||||
let outputSummary: string;
|
||||
let assistantContent = '';
|
||||
let acpReasoning = '';
|
||||
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
commands: manifestCommands,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
if (supportsAcp) {
|
||||
const result = await dispatchViaAcp({
|
||||
agent,
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
model: task.model ?? undefined,
|
||||
modeId: task.mode_id ?? undefined,
|
||||
thinkingOptionId: task.thinking_option_id ?? undefined,
|
||||
taskId,
|
||||
sessionId,
|
||||
chatId,
|
||||
messageId: assistantId,
|
||||
broker,
|
||||
signal: ac.signal,
|
||||
log,
|
||||
});
|
||||
assistantContent = result.output.slice(0, 50_000);
|
||||
acpReasoning = result.reasoningText.slice(0, 200_000);
|
||||
outputSummary = result.output.slice(0, 500);
|
||||
|
||||
// Store agent output as an assistant message
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${result.output.slice(0, 50_000)}, 'complete', clock_timestamp())
|
||||
`;
|
||||
await persistExternalAgentTurn(sql, assistantId, result.toolSnapshots, acpReasoning);
|
||||
} else {
|
||||
const result = await dispatchViaPty({
|
||||
agent,
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
model: task.model ?? undefined,
|
||||
modeId: task.mode_id ?? undefined,
|
||||
thinkingOptionId: task.thinking_option_id ?? undefined,
|
||||
signal: ac.signal,
|
||||
log,
|
||||
});
|
||||
assistantContent = (result.stdout || result.stderr || '(no output)').slice(0, 50_000);
|
||||
outputSummary = (result.stdout || result.stderr).slice(0, 500);
|
||||
|
||||
// Store agent output as an assistant message
|
||||
const content = result.stdout || result.stderr || '(no output)';
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${content.slice(0, 50_000)}, 'complete', clock_timestamp())
|
||||
`;
|
||||
if (assistantContent) {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: assistantContent,
|
||||
} as WsFrame);
|
||||
}
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantId}
|
||||
`;
|
||||
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`
|
||||
UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}
|
||||
@@ -322,6 +423,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -335,6 +437,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
apps/coder/src/services/host-exec.ts
Normal file
66
apps/coder/src/services/host-exec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Local shell exec on the BooCoder host (replaces deprecated ssh.ts for worktrees).
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface HostExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function hostExec(
|
||||
command: string,
|
||||
opts?: { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<HostExecResult> {
|
||||
return new Promise<HostExecResult>((resolve, reject) => {
|
||||
const child = spawn('bash', ['-lc', command], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
cleanup();
|
||||
reject(new Error('host exec aborted before start'));
|
||||
return;
|
||||
}
|
||||
opts.signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (opts?.timeoutMs) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`host exec timed out after ${opts.timeoutMs}ms`));
|
||||
}, opts.timeoutMs);
|
||||
}
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.stdin!.end();
|
||||
});
|
||||
}
|
||||
@@ -57,14 +57,29 @@ export async function startMcpServer(sql: Sql): Promise<void> {
|
||||
input: z.string().describe('Task description / prompt for the agent'),
|
||||
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
|
||||
model: z.string().optional().describe('Model override (optional)'),
|
||||
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
||||
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, 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<void> {
|
||||
input: z.string().describe('Task prompt'),
|
||||
agent: z.string().describe('Agent name (must match available_agents registry)'),
|
||||
model: z.string().optional().describe('Model override (optional)'),
|
||||
mode_id: z.string().optional().describe('Permission/mode id (optional)'),
|
||||
thinking_option_id: z.string().optional().describe('Thinking/effort option id (optional)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, 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<void> {
|
||||
`;
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
113
apps/coder/src/services/permission-waiter.ts
Normal file
113
apps/coder/src/services/permission-waiter.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
||||
*/
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
||||
import { isUnattendedMode } from './provider-manifest.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface PendingPermission {
|
||||
request: RequestPermissionRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingByTask = new Map<string, PendingPermission>();
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
toolTitle?: string;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface PermissionHooks {
|
||||
onPrompt?: (prompt: PermissionPrompt & { sessionId: string }) => void | Promise<void>;
|
||||
onResolved?: (taskId: string, sessionId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let hooks: PermissionHooks = {};
|
||||
|
||||
export function setPermissionHooks(next: PermissionHooks): void {
|
||||
hooks = next;
|
||||
}
|
||||
|
||||
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||
return {
|
||||
taskId,
|
||||
toolTitle: params.toolCall?.title ?? undefined,
|
||||
options: params.options.map((o) => ({
|
||||
optionId: o.optionId,
|
||||
label: o.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function waitForPermissionResponse(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
modeId: string | undefined,
|
||||
params: RequestPermissionRequest,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
if (isUnattendedMode(provider, modeId)) {
|
||||
const first = params.options[0];
|
||||
if (first) {
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: first.optionId } });
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = pendingByTask.get(taskId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.reject(new Error('superseded by newer permission request'));
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingByTask.delete(taskId);
|
||||
void hooks.onResolved?.(taskId, sessionId);
|
||||
resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = toPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return false;
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return null;
|
||||
return toPrompt(taskId, pending.request);
|
||||
}
|
||||
|
||||
export function cancelPendingPermission(taskId: string): void {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
}
|
||||
84
apps/coder/src/services/provider-commands.ts
Normal file
84
apps/coder/src/services/provider-commands.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Static slash-command hints per harness (interactive TUI / agent session).
|
||||
* Live ACP `available_commands_update` merges on top during dispatch.
|
||||
*/
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
|
||||
const CLAUDE_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available slash commands' },
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{ name: 'compact', description: 'Compact context window' },
|
||||
{ name: 'cost', description: 'Show session cost' },
|
||||
{ name: 'memory', description: 'Manage project memory' },
|
||||
{ name: 'model', description: 'Switch model' },
|
||||
{ name: 'permissions', description: 'View or change permission mode' },
|
||||
{ name: 'review', description: 'Review current changes' },
|
||||
{ name: 'status', description: 'Show session status' },
|
||||
{ name: 'vim', description: 'Toggle vim-style input' },
|
||||
];
|
||||
|
||||
const OPENCODE_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'new', description: 'Start a new session' },
|
||||
{ name: 'models', description: 'List or switch models' },
|
||||
{ name: 'agents', description: 'List or switch agents' },
|
||||
{ name: 'compact', description: 'Compact context' },
|
||||
{ name: 'share', description: 'Share session' },
|
||||
{ name: 'export', description: 'Export session' },
|
||||
];
|
||||
|
||||
const CURSOR_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available slash commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
{ name: 'compact', description: 'Compact context' },
|
||||
{ name: 'resume', description: 'Resume a prior session' },
|
||||
];
|
||||
|
||||
const GOOSE_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
{ name: 'compact', description: 'Compact context' },
|
||||
{ name: 'exit', description: 'Exit session' },
|
||||
];
|
||||
|
||||
const QWEN_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available slash commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
{ name: 'memory', description: 'Manage memory' },
|
||||
{ name: 'hooks', description: 'Manage hooks' },
|
||||
{ name: 'review', description: 'Review changes' },
|
||||
];
|
||||
|
||||
const COPILOT_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'explain', description: 'Explain selected code' },
|
||||
{ name: 'fix', description: 'Fix issues in context' },
|
||||
{ name: 'tests', description: 'Generate or run tests' },
|
||||
{ name: 'doc', description: 'Generate documentation' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
];
|
||||
|
||||
/** boocode harness uses /api/skills — merged on the frontend. */
|
||||
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
||||
claude: CLAUDE_COMMANDS,
|
||||
opencode: OPENCODE_COMMANDS,
|
||||
cursor: CURSOR_COMMANDS,
|
||||
goose: GOOSE_COMMANDS,
|
||||
qwen: QWEN_COMMANDS,
|
||||
copilot: COPILOT_COMMANDS,
|
||||
boocode: [],
|
||||
};
|
||||
|
||||
export function getManifestCommands(provider: string): AgentCommand[] {
|
||||
return PROVIDER_COMMANDS[provider] ?? [];
|
||||
}
|
||||
|
||||
export function mergeCommands(...lists: AgentCommand[][]): AgentCommand[] {
|
||||
const byName = new Map<string, AgentCommand>();
|
||||
for (const list of lists) {
|
||||
for (const cmd of list) {
|
||||
byName.set(cmd.name, cmd);
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
108
apps/coder/src/services/provider-manifest.ts
Normal file
108
apps/coder/src/services/provider-manifest.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Static provider mode metadata — lifted from Paseo provider-manifest.ts patterns.
|
||||
*/
|
||||
import type { ProviderMode } from './provider-types.js';
|
||||
|
||||
export interface ProviderManifestEntry {
|
||||
defaultModeId: string | null;
|
||||
modes: ProviderMode[];
|
||||
/** Claude effort levels exposed as thinking options on models. */
|
||||
thinkingOptions?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
const CLAUDE_MODES: ProviderMode[] = [
|
||||
{ id: 'default', label: 'Always Ask', description: 'Prompts for permission the first time a tool is used' },
|
||||
{ id: 'auto', label: 'Auto mode', description: 'Model classifier reviews permission prompts automatically' },
|
||||
{ id: 'acceptEdits', label: 'Accept File Edits', description: 'Automatically approves edit-focused tools' },
|
||||
{ id: 'plan', label: 'Plan Mode', description: 'Analyze without executing tools or edits' },
|
||||
{ id: 'bypassPermissions', label: 'Bypass', description: 'Skip all permission prompts', isUnattended: true },
|
||||
];
|
||||
|
||||
const OPENCODE_MODES: ProviderMode[] = [
|
||||
{ id: 'build', label: 'Build', description: 'Allows edits and tool execution' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
|
||||
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
|
||||
];
|
||||
|
||||
const COPILOT_MODES: ProviderMode[] = [
|
||||
{
|
||||
id: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
||||
label: 'Agent',
|
||||
description: 'Default agent mode',
|
||||
},
|
||||
{
|
||||
id: 'https://agentclientprotocol.com/protocol/session-modes#plan',
|
||||
label: 'Plan',
|
||||
description: 'Plan mode for multi-step work',
|
||||
},
|
||||
{
|
||||
id: 'allow-all',
|
||||
label: 'Allow All',
|
||||
description: 'Automatically approves all tool, path, and URL requests',
|
||||
isUnattended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const CURSOR_CLI_MODES: ProviderMode[] = [
|
||||
{ id: 'agent', label: 'Agent', description: 'Full agent capabilities with tool access' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
|
||||
{ id: 'ask', label: 'Ask', description: 'Q&A read-only mode' },
|
||||
];
|
||||
|
||||
const QWEN_PTY_MODES: ProviderMode[] = [
|
||||
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
||||
{ id: 'auto-edit', label: 'Auto Edit', description: 'Auto-approve edit tools' },
|
||||
{ id: 'auto', label: 'Auto', description: 'LLM classifier auto-approves safe actions' },
|
||||
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
|
||||
];
|
||||
|
||||
const CLAUDE_THINKING = [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra High' },
|
||||
{ id: 'max', label: 'Max' },
|
||||
];
|
||||
|
||||
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||
claude: {
|
||||
defaultModeId: 'default',
|
||||
modes: CLAUDE_MODES,
|
||||
thinkingOptions: CLAUDE_THINKING,
|
||||
},
|
||||
opencode: {
|
||||
defaultModeId: 'build',
|
||||
modes: OPENCODE_MODES,
|
||||
},
|
||||
copilot: {
|
||||
defaultModeId: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
||||
modes: COPILOT_MODES,
|
||||
},
|
||||
cursor: {
|
||||
defaultModeId: 'agent',
|
||||
modes: CURSOR_CLI_MODES,
|
||||
},
|
||||
goose: {
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
qwen: {
|
||||
defaultModeId: 'default',
|
||||
modes: QWEN_PTY_MODES,
|
||||
},
|
||||
};
|
||||
|
||||
export function getManifestModes(provider: string): ProviderMode[] {
|
||||
return PROVIDER_MANIFEST[provider]?.modes ?? [];
|
||||
}
|
||||
|
||||
export function getManifestDefaultModeId(provider: string): string | null {
|
||||
return PROVIDER_MANIFEST[provider]?.defaultModeId ?? null;
|
||||
}
|
||||
|
||||
export function isUnattendedMode(provider: string, modeId: string | undefined): boolean {
|
||||
if (!modeId) return false;
|
||||
const modes = getManifestModes(provider);
|
||||
return modes.some((m) => m.id === modeId && m.isUnattended);
|
||||
}
|
||||
73
apps/coder/src/services/provider-registry.ts
Normal file
73
apps/coder/src/services/provider-registry.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface ProviderDef {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: 'native' | 'acp' | 'pty';
|
||||
modelSource: 'llama-swap' | 'static' | 'probe';
|
||||
staticModels?: Array<{ id: string; label: string }>;
|
||||
/** Merge llama-swap models into probed list (OpenCode). */
|
||||
mergeLlamaSwap?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model discovery rules (see provider-snapshot.ts):
|
||||
* - boocode: llama-swap only
|
||||
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
||||
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
||||
* - cursor: ACP probe + cursor-agent models CLI fallback
|
||||
* - goose / copilot: ACP probe only
|
||||
* - claude: static manifest models + thinking options
|
||||
*/
|
||||
export const PROVIDERS: ProviderDef[] = [
|
||||
{
|
||||
name: 'boocode',
|
||||
label: 'BooCoder',
|
||||
transport: 'native',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
label: 'Cursor Agent',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
{
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
mergeLlamaSwap: true,
|
||||
},
|
||||
{
|
||||
name: 'goose',
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
{
|
||||
name: 'claude',
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
staticModels: [
|
||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'qwen',
|
||||
label: 'Qwen Code',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
{
|
||||
name: 'copilot',
|
||||
label: 'GitHub Copilot',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||
|
||||
/** External agents probed on host (excludes native boocode). */
|
||||
export const PROBED_AGENT_NAMES = PROVIDERS.filter((p) => p.name !== 'boocode').map((p) => p.name);
|
||||
266
apps/coder/src/services/provider-snapshot.ts
Normal file
266
apps/coder/src/services/provider-snapshot.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||
import {
|
||||
getManifestDefaultModeId,
|
||||
getManifestModes,
|
||||
PROVIDER_MANIFEST,
|
||||
} from './provider-manifest.js';
|
||||
import { probeAcpProvider } from './acp-probe.js';
|
||||
import { parseCursorAgentModelsOutput } from './cursor-models.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
supports_acp: boolean;
|
||||
models: ProviderModel[] | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||
if (!res.ok) return [];
|
||||
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
|
||||
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
return parseCursorAgentModelsOutput(stdout);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
||||
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
||||
return models.map((m) => ({
|
||||
...m,
|
||||
id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
|
||||
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
|
||||
if (!thinking?.length) return models;
|
||||
return models.map((m) => ({
|
||||
...m,
|
||||
thinkingOptions: thinking,
|
||||
defaultThinkingOptionId: 'medium',
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ProviderModel[] = [];
|
||||
for (const list of lists) {
|
||||
for (const m of list) {
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildProviderEntry(
|
||||
provider: ProviderDef,
|
||||
agentRow: AgentRow | undefined,
|
||||
llamaModels: ProviderModel[],
|
||||
cwd: string,
|
||||
): Promise<ProviderSnapshotEntry | null> {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const installed = isNative || !!agentRow;
|
||||
if (!installed) return null;
|
||||
|
||||
let transport = provider.transport;
|
||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
||||
transport = 'pty';
|
||||
}
|
||||
|
||||
const fallbackModes = getManifestModes(provider.name);
|
||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
||||
|
||||
if (isNative) {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models: llamaModels,
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: getManifestCommands(provider.name),
|
||||
};
|
||||
}
|
||||
|
||||
let models: ProviderModel[] = [];
|
||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models?.length) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
}
|
||||
|
||||
if (provider.name === 'claude') {
|
||||
models = attachClaudeThinking(models);
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
||||
if (probe.models.length > 0) {
|
||||
models = probe.models;
|
||||
} else if (provider.name === 'cursor' && agentRow.install_path) {
|
||||
models = await fetchCursorModelsCli(agentRow.install_path);
|
||||
} else if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
}
|
||||
|
||||
if (provider.name === 'qwen') {
|
||||
const settingsModels = await readQwenSettingsModels();
|
||||
models = mergeModels(models, settingsModels);
|
||||
}
|
||||
|
||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow.label ?? provider.label,
|
||||
transport,
|
||||
status: probe.ok ? 'ready' : 'error',
|
||||
installed: true,
|
||||
models,
|
||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
||||
error: probe.error,
|
||||
};
|
||||
}
|
||||
|
||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
||||
if (provider.name === 'qwen') {
|
||||
if (models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
};
|
||||
}
|
||||
|
||||
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
||||
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export async function getProviderSnapshot(
|
||||
sql: Sql,
|
||||
config: Config,
|
||||
cwd?: string,
|
||||
force = false,
|
||||
): Promise<ProviderSnapshotEntry[]> {
|
||||
const resolvedCwd = cwd?.trim() || homedir();
|
||||
const cacheKey = resolvedCwd;
|
||||
const cached = snapshotCache.get(cacheKey);
|
||||
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||
return cached.entries;
|
||||
}
|
||||
|
||||
const inflight = snapshotInflight.get(cacheKey);
|
||||
if (!force && inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
const agents = await sql<AgentRow[]>`
|
||||
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
||||
`;
|
||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||
|
||||
const built = await Promise.all(
|
||||
PROVIDERS.map((provider) =>
|
||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
||||
),
|
||||
);
|
||||
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
||||
|
||||
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||
return entries;
|
||||
};
|
||||
|
||||
const promise = build().finally(() => {
|
||||
snapshotInflight.delete(cacheKey);
|
||||
});
|
||||
snapshotInflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function clearProviderSnapshotCache(): void {
|
||||
snapshotCache.clear();
|
||||
snapshotInflight.clear();
|
||||
}
|
||||
|
||||
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||
export async function persistProbedModels(
|
||||
sql: Sql,
|
||||
entries: ProviderSnapshotEntry[],
|
||||
log: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||
await sql`
|
||||
UPDATE available_agents
|
||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||
WHERE name = ${entry.name}
|
||||
`;
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
||||
}
|
||||
}
|
||||
51
apps/coder/src/services/provider-types.ts
Normal file
51
apps/coder/src/services/provider-types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Auto-approve tool permissions when this mode is selected. */
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
/**
|
||||
* PTY dispatch — runs external agents on the host via SSH.
|
||||
*
|
||||
* For agents without ACP support (claude, pi), we pipe the task into their
|
||||
* non-interactive mode and capture stdout/stderr. The agent runs in a git
|
||||
* worktree so it can modify files freely.
|
||||
*
|
||||
* Supported agents:
|
||||
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
|
||||
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD)
|
||||
* - qwen: `qwen -p <task> --output-format stream-json` (NDJSON structured output)
|
||||
* - goose: stub (not yet supported)
|
||||
* - pi: stub (not yet supported)
|
||||
* PTY dispatch — runs external agents directly on the host.
|
||||
*/
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { sshSpawnWithStdin } from './ssh.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface DispatchResult {
|
||||
exitCode: number;
|
||||
@@ -26,62 +15,68 @@ export interface PtyDispatchOpts {
|
||||
task: string;
|
||||
worktreePath: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
installPath?: string;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shell command that runs the agent non-interactively.
|
||||
* The command will be executed inside `cd <worktreePath> && ...`.
|
||||
*/
|
||||
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
|
||||
// Escape the task for embedding in a shell command
|
||||
const escapedTask = task.replace(/'/g, "'\\''");
|
||||
interface PtySpawnSpec {
|
||||
binary: string;
|
||||
args: string[];
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
function buildPtySpawnSpec(
|
||||
agent: string,
|
||||
task: string,
|
||||
model?: string,
|
||||
modeId?: string,
|
||||
thinkingOptionId?: string,
|
||||
installPath?: string,
|
||||
): PtySpawnSpec | null {
|
||||
const binary = installPath ?? agent;
|
||||
|
||||
switch (agent) {
|
||||
case 'claude':
|
||||
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
|
||||
return model
|
||||
? `echo '${escapedTask}' | claude -p --model '${model}'`
|
||||
: `echo '${escapedTask}' | claude -p`;
|
||||
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':
|
||||
// opencode non-interactive: pipe task via stdin
|
||||
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
|
||||
return model
|
||||
? `echo '${escapedTask}' | opencode --model '${model}'`
|
||||
: `echo '${escapedTask}' | opencode`;
|
||||
|
||||
case 'qwen':
|
||||
// Qwen Code: structured JSON output mode for parseable events
|
||||
return model
|
||||
? `qwen -p '${escapedTask}' --model '${model}' --output-format stream-json`
|
||||
: `qwen -p '${escapedTask}' --output-format stream-json`;
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['--model', model] : [],
|
||||
stdin: task,
|
||||
};
|
||||
|
||||
case 'goose':
|
||||
// Not yet verified for non-interactive use
|
||||
return null;
|
||||
|
||||
case 'pi':
|
||||
// Not yet verified for non-interactive use
|
||||
return null;
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to an external agent via SSH.
|
||||
*
|
||||
* The agent runs in the worktree directory on the host. stdout/stderr are
|
||||
* captured in full and returned. The SSH process is killed on abort signal.
|
||||
*/
|
||||
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
||||
const { agent, task, worktreePath, model, signal, log } = opts;
|
||||
const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log } = opts;
|
||||
|
||||
const agentCmd = buildAgentCommand(agent, task, model);
|
||||
if (!agentCmd) {
|
||||
const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
|
||||
if (!cmd) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
@@ -89,22 +84,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap in cd to the worktree
|
||||
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
|
||||
|
||||
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
|
||||
log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
|
||||
|
||||
return new Promise<DispatchResult>((resolve, reject) => {
|
||||
const child = sshSpawnWithStdin(fullCommand, '');
|
||||
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
|
||||
// stdin via echo piping, the command itself handles the piping on the remote
|
||||
// side. We just need the SSH tunnel.
|
||||
const child = spawn(cmd.binary, cmd.args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
|
||||
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
|
||||
// provides its own stdin. So we should use sshSpawn (no local stdin needed)
|
||||
// or just let the empty stdin close — the remote shell handles piping internally.
|
||||
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
|
||||
if (cmd.stdin) {
|
||||
child.stdin!.write(cmd.stdin);
|
||||
}
|
||||
child.stdin!.end();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -117,7 +109,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
// Give it a moment then force-kill
|
||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||
}
|
||||
};
|
||||
|
||||
21
apps/coder/src/services/qwen-settings.ts
Normal file
21
apps/coder/src/services/qwen-settings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ProviderModel } from './provider-types.js';
|
||||
|
||||
const QWEN_SETTINGS_PATH = join(homedir(), '.qwen', 'settings.json');
|
||||
|
||||
export async function readQwenSettingsModels(): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const raw = await readFile(QWEN_SETTINGS_PATH, 'utf8');
|
||||
if (!raw.trim()) return [];
|
||||
const settings = JSON.parse(raw) as {
|
||||
modelProviders?: { openai?: Array<{ id: string }> };
|
||||
};
|
||||
const openaiModels = settings?.modelProviders?.openai;
|
||||
if (!Array.isArray(openaiModels)) return [];
|
||||
return openaiModels.map((m) => ({ id: m.id, label: m.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* SSH helper — spawns commands on the host via SSH.
|
||||
*
|
||||
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).
|
||||
* They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the
|
||||
* Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53).
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
|
||||
export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53';
|
||||
export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop';
|
||||
|
||||
/** Common SSH args — strict host checking disabled for container-to-host trust. */
|
||||
const SSH_BASE_ARGS = [
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'BatchMode=yes',
|
||||
];
|
||||
|
||||
export interface SshExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command on the host via SSH, collecting all output.
|
||||
* Returns when the remote process exits.
|
||||
*/
|
||||
export async function sshExec(
|
||||
command: string,
|
||||
opts?: { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<SshExecResult> {
|
||||
return new Promise<SshExecResult>((resolve, reject) => {
|
||||
const child = spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
|
||||
// Abort signal
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
cleanup();
|
||||
reject(new Error('SSH exec aborted before start'));
|
||||
return;
|
||||
}
|
||||
opts.signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
// Timeout
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (opts?.timeoutMs) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`));
|
||||
}, opts.timeoutMs);
|
||||
}
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Close stdin immediately — we're not sending input via sshExec
|
||||
child.stdin!.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an SSH child process with a command on the host.
|
||||
* Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY).
|
||||
*/
|
||||
export function sshSpawn(command: string): ChildProcess {
|
||||
return spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an SSH child process that pipes stdin through.
|
||||
* Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`).
|
||||
*/
|
||||
export function sshSpawnWithStdin(command: string, input: string): ChildProcess {
|
||||
const child = spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Write the input and close stdin
|
||||
child.stdin!.write(input);
|
||||
child.stdin!.end();
|
||||
|
||||
return child;
|
||||
}
|
||||
@@ -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<string> {
|
||||
// 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(() => {});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { registerArtifactRoutes } from './routes/artifacts.js';
|
||||
import { registerChatRoutes } from './routes/chats.js';
|
||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
import { registerCoderProxy } from './routes/coder-proxy.js';
|
||||
import { registerModelRoutes } from './routes/models.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerSkillsRoutes } from './routes/skills.js';
|
||||
@@ -212,36 +213,10 @@ async function main() {
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the
|
||||
// SPA's HTTP requests going through a single origin (avoids CORS). WS for
|
||||
// the coder pane connects directly to boocoder:9502 from the browser (same
|
||||
// Tailscale network — no CORS issue for WebSocket upgrade requests).
|
||||
// v2.0.0: reverse proxy /api/coder/* to boocoder (HTTP + WS). CoderPane
|
||||
// connects WS through /api/coder/ws/sessions/:id on the same origin.
|
||||
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
||||
app.all('/api/coder/*', async (req, reply) => {
|
||||
const targetPath = req.url.replace('/api/coder', '/api');
|
||||
const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
|
||||
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: req.method as string,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
reply.code(res.status);
|
||||
for (const [key, value] of res.headers) {
|
||||
if (key === 'transfer-encoding') continue;
|
||||
reply.header(key, value);
|
||||
}
|
||||
const body = await res.text();
|
||||
return reply.send(body);
|
||||
} catch (err) {
|
||||
app.log.error({ err, targetUrl }, 'coder proxy error');
|
||||
reply.code(502).send({ error: 'boocoder backend unavailable' });
|
||||
}
|
||||
});
|
||||
registerCoderProxy(app, BOOCODER_ORIGIN);
|
||||
|
||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||
if (existsSync(webDist)) {
|
||||
|
||||
91
apps/server/src/routes/coder-proxy.ts
Normal file
91
apps/server/src/routes/coder-proxy.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
function boocoderWsUrl(origin: string, path: string): string {
|
||||
const u = new URL(origin);
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.pathname = path;
|
||||
u.search = '';
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-proxy BooCoder HTTP + WebSocket through BooChat's single origin.
|
||||
* WS must be registered before the HTTP catch-all — fetch() cannot upgrade.
|
||||
*/
|
||||
export function registerCoderProxy(app: FastifyInstance, boocoderOrigin: string): void {
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/coder/ws/sessions/:sessionId',
|
||||
{ websocket: true },
|
||||
(clientSocket, req) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const target = boocoderWsUrl(boocoderOrigin, `/api/ws/sessions/${sessionId}`);
|
||||
const upstream = new WebSocket(target);
|
||||
|
||||
upstream.on('open', () => {
|
||||
app.log.debug({ sessionId }, 'coder ws proxy: upstream connected');
|
||||
});
|
||||
|
||||
upstream.on('message', (data, isBinary) => {
|
||||
if (clientSocket.readyState !== clientSocket.OPEN) return;
|
||||
clientSocket.send(data, { binary: isBinary });
|
||||
});
|
||||
|
||||
upstream.on('close', (code, reason) => {
|
||||
if (clientSocket.readyState === clientSocket.OPEN) {
|
||||
clientSocket.close(code, reason.toString());
|
||||
}
|
||||
});
|
||||
|
||||
upstream.on('error', (err) => {
|
||||
app.log.warn({ err, sessionId, target }, 'coder ws proxy: upstream error');
|
||||
if (clientSocket.readyState === clientSocket.OPEN) {
|
||||
clientSocket.close(1011, 'upstream error');
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on('message', (data, isBinary) => {
|
||||
if (upstream.readyState !== WebSocket.OPEN) return;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
|
||||
clientSocket.on('close', () => {
|
||||
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.all('/api/coder/*', async (req, reply) => {
|
||||
const targetPath = req.url.replace('/api/coder', '/api');
|
||||
const targetUrl = `${boocoderOrigin}${targetPath}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
|
||||
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: req.method as string,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
reply.code(res.status);
|
||||
for (const [key, value] of res.headers) {
|
||||
if (key === 'transfer-encoding') continue;
|
||||
reply.header(key, value);
|
||||
}
|
||||
const body = await res.text();
|
||||
return reply.send(body);
|
||||
} catch (err) {
|
||||
app.log.error({ err, targetUrl }, 'coder proxy error');
|
||||
reply.code(502).send({ error: 'boocoder backend unavailable' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,8 @@ const WorkspacePaneZ = z.object({
|
||||
kind: z.enum([
|
||||
'chat',
|
||||
'terminal',
|
||||
'agent',
|
||||
'coder',
|
||||
'agent', // legacy alias — normalized to coder on write
|
||||
'empty',
|
||||
'settings',
|
||||
'markdown_artifact',
|
||||
@@ -307,9 +308,12 @@ export function registerSessionRoutes(
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
||||
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||
);
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions
|
||||
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
|
||||
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Chat } from '../types/api.js';
|
||||
import { getSkillBody, listSkills } from '../services/skills.js';
|
||||
import {
|
||||
buildSkillInvokeSyntheticFrames,
|
||||
DEFAULT_SKILL_USER_MESSAGE,
|
||||
runSkillInvokeTransaction,
|
||||
} from '../services/skill-invoke.js';
|
||||
|
||||
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
||||
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
||||
@@ -35,8 +39,6 @@ const SkillInvokeBody = z.object({
|
||||
user_message: z.string().max(64_000).nullable().optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
|
||||
|
||||
export function registerSkillsRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
@@ -62,7 +64,9 @@ export function registerSkillsRoutes(
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { skill_name } = parsed.data;
|
||||
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
|
||||
const userText = parsed.data.user_message?.trim()
|
||||
? parsed.data.user_message
|
||||
: DEFAULT_SKILL_USER_MESSAGE;
|
||||
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
@@ -80,87 +84,20 @@ export function registerSkillsRoutes(
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||
}
|
||||
|
||||
const toolCallId = randomUUID();
|
||||
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
|
||||
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
||||
// content, so one part at seq 0.
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||
id: toolCallId,
|
||||
name: 'skill_use',
|
||||
args: { name: skill_name },
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||
`;
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return {
|
||||
synth_assistant_id: synthAssistant!.id,
|
||||
tool_message_id: toolMsg!.id,
|
||||
user_message_id: userMsg!.id,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
};
|
||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||
sessionId,
|
||||
chatId: chat.id,
|
||||
skillName: skill_name,
|
||||
skillBody: body,
|
||||
userText,
|
||||
});
|
||||
|
||||
// Synthetic frames so useSessionStream's reducer reflects the new
|
||||
// history without a refetch. Frame shapes match the streaming-inference
|
||||
// protocol (see services/inference.ts InferenceFrame).
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
role: 'assistant',
|
||||
});
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
tool_call: toolCalls[0]!,
|
||||
});
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
});
|
||||
// The tool_result frame's reducer branch creates the tool-role message
|
||||
// in-place when it doesn't already exist — no separate message_started
|
||||
// is needed for the tool side.
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id: toolCallId,
|
||||
chat_id: chat.id,
|
||||
output: body,
|
||||
truncated: false,
|
||||
});
|
||||
for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) {
|
||||
handlers.publishSessionFrame(sessionId, frame);
|
||||
}
|
||||
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
|
||||
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAgentRegistryMarkdown, parseAgentsMd } from '../agents.js';
|
||||
|
||||
describe('isAgentRegistryMarkdown', () => {
|
||||
it('rejects Cursor navigation AGENTS.md at repo root', () => {
|
||||
expect(
|
||||
isAgentRegistryMarkdown('# Agent navigation\n\n## Doc map\n'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the global data/AGENTS.md registry shape', () => {
|
||||
expect(isAgentRegistryMarkdown('# Agents\n\n## Code Reviewer\n---\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAgentsMd', () => {
|
||||
it('does not emit errors for navigation sections when file is skipped upstream', () => {
|
||||
// When isAgentRegistryMarkdown returns false, getAgentsForProject never calls this.
|
||||
// Sanity: a nav-shaped file would produce six "missing fence" errors if parsed.
|
||||
const nav = `# Agent navigation
|
||||
|
||||
## Doc map
|
||||
| Need | Read |
|
||||
|------|------|
|
||||
|
||||
## Task routing
|
||||
Start here
|
||||
`;
|
||||
const r = parseAgentsMd(nav);
|
||||
expect(r.agents).toHaveLength(0);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -309,6 +309,14 @@ export function parseAgentsMd(content: string): ParseResult {
|
||||
return { agents, errors };
|
||||
}
|
||||
|
||||
/** True when a file at `<project>/AGENTS.md` is an agent registry, not Cursor/doc nav. */
|
||||
export function isAgentRegistryMarkdown(content: string): boolean {
|
||||
const firstLine = content.trimStart().split('\n')[0]?.trim() ?? '';
|
||||
// BooCode monorepo root AGENTS.md is navigation only; registry is /data/AGENTS.md.
|
||||
if (firstLine === '# Agent navigation') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- mtime-keyed cache + public API ----------------------------------------
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -397,7 +405,7 @@ export async function getAgentsForProject(projectPath: string): Promise<AgentsRe
|
||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
|
||||
errors.push(...r.errors);
|
||||
}
|
||||
if (projectContent !== null) {
|
||||
if (projectContent !== null && isAgentRegistryMarkdown(projectContent)) {
|
||||
const r = parseAgentsMd(projectContent);
|
||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
|
||||
errors.push(...r.errors);
|
||||
|
||||
@@ -37,6 +37,34 @@ export interface OpenAiMessage {
|
||||
// omit it and exercise the byte-stability surface directly through
|
||||
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
|
||||
// updates regardless of whether log is passed.
|
||||
function toolResultIdsFollowing(history: Message[], assistantIdx: number): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (let j = assistantIdx + 1; j < history.length; j++) {
|
||||
const row = history[j]!;
|
||||
if (row.role === 'user' || row.role === 'assistant') break;
|
||||
if (row.role === 'tool' && row.tool_results?.tool_call_id) {
|
||||
ids.add(row.tool_results.tool_call_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null {
|
||||
for (let k = toolIdx - 1; k >= 0; k--) {
|
||||
const row = history[k]!;
|
||||
if (row.role === 'user') break;
|
||||
if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean {
|
||||
const assistant = history[assistantIdx]!;
|
||||
if (!assistant.tool_calls?.length) return false;
|
||||
const fulfilled = toolResultIdsFollowing(history, assistantIdx);
|
||||
return assistant.tool_calls.every((tc) => fulfilled.has(tc.id));
|
||||
}
|
||||
|
||||
export async function buildMessagesPayload(
|
||||
session: Session,
|
||||
project: Project,
|
||||
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
|
||||
if (m.role === 'tool') {
|
||||
const tr = m.tool_results;
|
||||
if (!tr) continue;
|
||||
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
|
||||
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
|
||||
continue;
|
||||
}
|
||||
const outputText = tr.error
|
||||
? `error: ${tr.error}`
|
||||
: typeof tr.output === 'string'
|
||||
@@ -115,11 +147,15 @@ export async function buildMessagesPayload(
|
||||
content: m.content && m.content.length > 0 ? m.content : null,
|
||||
};
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
if (assistantToolCallsArePayloadComplete(history, i)) {
|
||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
}
|
||||
// Orphaned tool_calls (no matching tool rows) are stripped so the
|
||||
// upstream API does not reject the payload on the next user turn.
|
||||
}
|
||||
// v1.13.1-C: collapse reasoning_parts into a single string. The view
|
||||
// returns them ordered by sequence; multiple reasoning parts on one
|
||||
@@ -127,6 +163,11 @@ export async function buildMessagesPayload(
|
||||
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
||||
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
|
||||
}
|
||||
const hasPayload =
|
||||
(msg.content != null && msg.content.trim().length > 0) ||
|
||||
(msg.tool_calls != null && msg.tool_calls.length > 0) ||
|
||||
(msg.reasoning != null && msg.reasoning.length > 0);
|
||||
if (!hasPayload) continue;
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
@@ -142,7 +183,7 @@ export async function loadContext(
|
||||
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
||||
const sessionRows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled
|
||||
agent_id, web_search_enabled, allowed_read_paths
|
||||
FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) return null;
|
||||
|
||||
@@ -36,6 +36,8 @@ export async function runCapHitSummary(
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||
|
||||
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
|
||||
|
||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
|
||||
|
||||
@@ -195,8 +197,6 @@ export async function runCapHitSummary(
|
||||
updated_at: sessRow!.updated_at,
|
||||
});
|
||||
|
||||
await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
|
||||
|
||||
// Status frame fires last so the dot color reflects the terminal state.
|
||||
// Success → idle, abort → idle (user-driven stop), error → error+reason.
|
||||
if (summaryOk) {
|
||||
|
||||
148
apps/server/src/services/skill-invoke.ts
Normal file
148
apps/server/src/services/skill-invoke.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export const DEFAULT_SKILL_USER_MESSAGE = 'Apply this skill.';
|
||||
|
||||
export interface SkillInvokeTransactionResult {
|
||||
synth_assistant_id: string;
|
||||
tool_message_id: string;
|
||||
user_message_id: string;
|
||||
assistant_message_id: string;
|
||||
}
|
||||
|
||||
export interface SkillInvokeToolCall {
|
||||
id: string;
|
||||
name: 'skill_use';
|
||||
args: { name: string };
|
||||
}
|
||||
|
||||
export type SkillInvokeSessionFrame = Record<string, unknown> & { type: string };
|
||||
|
||||
export async function runSkillInvokeTransaction(
|
||||
sql: Sql,
|
||||
args: {
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
skillName: string;
|
||||
skillBody: string;
|
||||
userText: string;
|
||||
},
|
||||
): Promise<{ result: SkillInvokeTransactionResult; toolCall: SkillInvokeToolCall }> {
|
||||
const toolCallId = randomUUID();
|
||||
const toolCall: SkillInvokeToolCall = {
|
||||
id: toolCallId,
|
||||
name: 'skill_use',
|
||||
args: { name: args.skillName },
|
||||
};
|
||||
const toolResults = {
|
||||
tool_call_id: toolCallId,
|
||||
output: args.skillBody,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||
id: toolCallId,
|
||||
name: 'skill_use',
|
||||
args: { name: args.skillName },
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${args.sessionId}, ${args.chatId}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||
`;
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${args.sessionId}, ${args.chatId}, 'user', ${args.userText}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${args.sessionId}, ${args.chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${args.sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${args.chatId}`;
|
||||
return {
|
||||
synth_assistant_id: synthAssistant!.id,
|
||||
tool_message_id: toolMsg!.id,
|
||||
user_message_id: userMsg!.id,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
};
|
||||
});
|
||||
|
||||
return { result, toolCall };
|
||||
}
|
||||
|
||||
export function buildSkillInvokeSyntheticFrames(
|
||||
chatId: string,
|
||||
result: SkillInvokeTransactionResult,
|
||||
toolCall: SkillInvokeToolCall,
|
||||
skillBody: string,
|
||||
): SkillInvokeSessionFrame[] {
|
||||
return [
|
||||
{
|
||||
type: 'message_started',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
tool_call: toolCall,
|
||||
},
|
||||
{
|
||||
type: 'message_complete',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id: toolCall.id,
|
||||
chat_id: chatId,
|
||||
output: skillBody,
|
||||
truncated: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSkillInvokeUserFrames(
|
||||
chatId: string,
|
||||
userMessageId: string,
|
||||
userText: string,
|
||||
): SkillInvokeSessionFrame[] {
|
||||
return [
|
||||
{
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content: userText,
|
||||
},
|
||||
{
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,6 +13,13 @@ import type {
|
||||
Skill,
|
||||
AskUserAnswer,
|
||||
ToolCostStat,
|
||||
ProviderSnapshotEntry,
|
||||
CoderSendMessageBody,
|
||||
CoderSendMessageResponse,
|
||||
CoderMessageWire,
|
||||
CoderTaskDetail,
|
||||
PermissionPrompt,
|
||||
AgentCommand,
|
||||
} from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -298,6 +305,49 @@ export const api = {
|
||||
|
||||
models: () => request<ModelInfo[]>('/api/models'),
|
||||
|
||||
coder: {
|
||||
snapshot: (cwd?: string) => {
|
||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||
},
|
||||
refreshProviders: () =>
|
||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
||||
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
getTaskPermission: (taskId: string) =>
|
||||
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
|
||||
respondTaskPermission: (taskId: string, optionId: string | null) =>
|
||||
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ option_id: optionId }),
|
||||
}),
|
||||
getTaskCommands: (taskId: string) =>
|
||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||
getTask: (taskId: string) =>
|
||||
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
||||
listMessages: (sessionId: string, chatId?: string) =>
|
||||
request<CoderMessageWire[]>(
|
||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||
),
|
||||
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
|
||||
request<{
|
||||
user_message_id: string;
|
||||
assistant_message_id: string;
|
||||
synth_assistant_id: string;
|
||||
tool_message_id: string;
|
||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
pane_id: paneId,
|
||||
skill_name: skillName,
|
||||
user_message: userMessage,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
agents: {
|
||||
list: (projectId: string) =>
|
||||
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
||||
|
||||
@@ -206,6 +206,100 @@ export interface ModelInfo {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
toolTitle?: string;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CoderSendMessageBody {
|
||||
content: string;
|
||||
pane_id: string;
|
||||
chat_id?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode_id?: string;
|
||||
thinking_option_id?: string;
|
||||
}
|
||||
|
||||
export interface CoderSendMessageResponse {
|
||||
user_message_id?: string;
|
||||
assistant_message_id?: string;
|
||||
task_id?: string;
|
||||
dispatched?: boolean;
|
||||
}
|
||||
|
||||
export interface CoderMessageWire {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CoderTaskDetail {
|
||||
id: string;
|
||||
state: 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'cancelled';
|
||||
input: string;
|
||||
output_summary: string | null;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
session_id: string | null;
|
||||
}
|
||||
|
||||
export interface SidebarSession {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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',
|
||||
|
||||
39
apps/web/src/components/AgentCommandsHint.tsx
Normal file
39
apps/web/src/components/AgentCommandsHint.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { AgentCommand } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
commands: AgentCommand[];
|
||||
}
|
||||
|
||||
export function AgentCommandsHint({ commands }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-2 mb-1 rounded-md border border-border/60 bg-muted/30 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-muted-foreground hover:text-foreground max-md:min-h-[44px]"
|
||||
>
|
||||
<span>Slash commands ({commands.length})</span>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
|
||||
{commands.map((cmd) => (
|
||||
<li key={cmd.name} className="font-mono">
|
||||
<span className="text-primary/80">/{cmd.name}</span>
|
||||
{cmd.description && (
|
||||
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
apps/web/src/components/AgentComposerBar.tsx
Normal file
308
apps/web/src/components/AgentComposerBar.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PREFS_KEY = 'boocode.coder.agent-prefs';
|
||||
|
||||
|
||||
type ProviderPrefs = Record<string, {
|
||||
model: string;
|
||||
modeId: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}>;
|
||||
|
||||
function loadPrefs(): ProviderPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
return raw ? (JSON.parse(raw) as ProviderPrefs) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(prefs: ProviderPrefs): void {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
function defaultsForProvider(entry: ProviderSnapshotEntry): AgentSessionConfig {
|
||||
const model =
|
||||
entry.models.find((m) => m.isDefault)?.id ??
|
||||
entry.models[0]?.id ??
|
||||
'';
|
||||
const selectedModel = entry.models.find((m) => m.id === model);
|
||||
const modeId = entry.defaultModeId ?? entry.modes[0]?.id ?? null;
|
||||
const thinkingOptionId =
|
||||
selectedModel?.defaultThinkingOptionId ??
|
||||
selectedModel?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
||||
selectedModel?.thinkingOptions?.[0]?.id ??
|
||||
null;
|
||||
|
||||
return {
|
||||
provider: entry.name,
|
||||
model,
|
||||
modeId,
|
||||
thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfig(
|
||||
entry: ProviderSnapshotEntry,
|
||||
prefs: ProviderPrefs,
|
||||
): AgentSessionConfig {
|
||||
const saved = prefs[entry.name];
|
||||
const base = defaultsForProvider(entry);
|
||||
|
||||
const model =
|
||||
saved?.model && entry.models.some((m) => m.id === saved.model)
|
||||
? saved.model
|
||||
: base.model;
|
||||
|
||||
const selectedModel = entry.models.find((m) => m.id === model);
|
||||
const modeId =
|
||||
saved?.modeId && entry.modes.some((m) => m.id === saved.modeId)
|
||||
? saved.modeId
|
||||
: base.modeId;
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? [];
|
||||
const thinkingOptionId =
|
||||
saved?.thinkingOptionId &&
|
||||
thinkingOptions.some((t) => t.id === saved.thinkingOptionId)
|
||||
? saved.thinkingOptionId
|
||||
: base.thinkingOptionId;
|
||||
|
||||
return { provider: entry.name, model, modeId, thinkingOptionId };
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
options: Array<{ id: string; label: string }>;
|
||||
onPick: (id: string) => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
||||
const { isMobile } = useViewport();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||
|
||||
const list = (
|
||||
<div className="py-1">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`${label}: ${currentLabel}`}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{icon ?? <Cpu className="size-4" />}
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||
<div className="px-2">{list}</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
||||
{options.map((o) => (
|
||||
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
|
||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||
{o.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectPath?: string;
|
||||
value: AgentSessionConfig;
|
||||
onChange: (next: AgentSessionConfig) => void;
|
||||
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
[allEntries],
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
hydratedRef.current = false;
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries?.length || hydratedRef.current) return;
|
||||
hydratedRef.current = true;
|
||||
const prefs = loadPrefs();
|
||||
const entry =
|
||||
entries.find((e) => e.name === value.provider) ??
|
||||
entries.find((e) => e.name === 'boocode') ??
|
||||
entries[0];
|
||||
if (!entry) return;
|
||||
onChange(resolveConfig(entry, prefs));
|
||||
}, [entries, onChange, value.provider]);
|
||||
|
||||
const currentEntry = useMemo(
|
||||
() => entries?.find((e) => e.name === value.provider),
|
||||
[entries, value.provider],
|
||||
);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => currentEntry?.models.find((m) => m.id === value.model),
|
||||
[currentEntry, value.model],
|
||||
);
|
||||
|
||||
const thinkingOptions = currentModel?.thinkingOptions ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
onProviderCommandsChange?.(currentEntry?.commands ?? []);
|
||||
}, [currentEntry, onProviderCommandsChange]);
|
||||
|
||||
function persist(next: AgentSessionConfig): void {
|
||||
const prefs = loadPrefs();
|
||||
prefs[next.provider] = {
|
||||
model: next.model,
|
||||
modeId: next.modeId,
|
||||
thinkingOptionId: next.thinkingOptionId,
|
||||
};
|
||||
savePrefs(prefs);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function pickProvider(name: string): void {
|
||||
const entry = entries?.find((e) => e.name === name);
|
||||
if (!entry) return;
|
||||
persist(resolveConfig(entry, loadPrefs()));
|
||||
}
|
||||
|
||||
function pickModel(model: string): void {
|
||||
const entry = currentEntry;
|
||||
if (!entry) return;
|
||||
const selected = entry.models.find((m) => m.id === model);
|
||||
const thinkingOptionId =
|
||||
selected?.defaultThinkingOptionId ??
|
||||
selected?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
||||
selected?.thinkingOptions?.[0]?.id ??
|
||||
null;
|
||||
persist({ ...value, model, thinkingOptionId });
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await api.coder.refreshProviders();
|
||||
await refreshProviderSnapshot(projectPath);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">Loading agents…</div>
|
||||
);
|
||||
}
|
||||
|
||||
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<CompactPicker
|
||||
label="Provider"
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={<Cpu className="size-3 shrink-0" />}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
disabled={modeOptions.length === 0}
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Model"
|
||||
value={value.model}
|
||||
disabled={modelOptions.length === 0}
|
||||
options={modelOptions}
|
||||
onPick={pickModel}
|
||||
/>
|
||||
{thinkingOpts.length > 0 && (
|
||||
<CompactPicker
|
||||
label="Thinking"
|
||||
value={value.thinkingOptionId ?? ''}
|
||||
options={thinkingOpts}
|
||||
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { ContextBar } from '@/components/ContextBar';
|
||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { api } from '@/api/client';
|
||||
import type { Message } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -87,10 +88,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||||
// the input and stays open while the input is `/<word>` with no whitespace.
|
||||
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||||
// v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand
|
||||
// now reads the live textarea rect via inputRef (textareaRef below) so it can
|
||||
// recompute on visualViewport changes (iOS keyboard open/close), so the
|
||||
// anchorRect field is no longer needed in this state.
|
||||
// SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
|
||||
// so it can recompute on visualViewport changes (iOS keyboard open/close).
|
||||
const [slashState, setSlashState] = useState<{
|
||||
query: string;
|
||||
} | null>(null);
|
||||
@@ -168,13 +167,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// input parses to a known skill. Falls through to onSend for unknown
|
||||
// slash names (literal text) or when slash dispatch isn't wired.
|
||||
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (match && skillsLookup.has(match[1]!)) {
|
||||
const skillName = match[1]!;
|
||||
const args = (match[2] ?? '').trim();
|
||||
const parsed = parseSlashInput(text);
|
||||
if (parsed && skillsLookup.has(parsed.cmdName)) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSlashCommand(skillName, args);
|
||||
await onSlashCommand(parsed.cmdName, parsed.args);
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
setSlashState(null);
|
||||
@@ -268,8 +265,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||||
// skill name). Hand off to args mode the moment a space appears or the
|
||||
// slash leaves position 0.
|
||||
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
|
||||
const query = newValue.slice(1);
|
||||
if (onSlashCommand && isSlashCommandToken(newValue)) {
|
||||
const query = slashQuery(newValue);
|
||||
if (!slashState) {
|
||||
setSlashState({ query });
|
||||
} else if (slashState.query !== query) {
|
||||
@@ -496,7 +493,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionState?.open) return;
|
||||
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// it consume them so the textarea doesn't also submit on Enter.
|
||||
if (slashState) return;
|
||||
// IME safety: never act on Enter while an IME composition is in flight
|
||||
@@ -658,12 +655,13 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
/>
|
||||
)}
|
||||
{slashState && (
|
||||
<SkillSlashCommand
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
skills={skills}
|
||||
items={skills}
|
||||
inputRef={textareaRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
emptyLabel="No skills available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -183,13 +183,13 @@ export function ChatTabBar({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
@@ -37,14 +38,15 @@ function useTerminals(): TerminalRegistration[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
// Wrap a message body with a right-click context menu offering "Send to
|
||||
// terminal → <pane name>". The submenu is disabled when nothing is selected
|
||||
// or no terminal panes are open; clicking a target emits a sendToTerminal
|
||||
// event that TerminalPane subscribes to (filtered by pane_id).
|
||||
// Wrap a message body with a right-click context menu offering Copy and
|
||||
// "Send to terminal → <pane name>". Send is disabled when nothing is
|
||||
// selected or no terminal panes are open; clicking a target emits a
|
||||
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
|
||||
function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||
const [selection, setSelection] = useState('');
|
||||
const terminals = useTerminals();
|
||||
const canSend = selection.length > 0 && terminals.length > 0;
|
||||
const hasSelection = selection.length > 0;
|
||||
const canSend = hasSelection && terminals.length > 0;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@@ -57,6 +59,17 @@ function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => {
|
||||
void navigator.clipboard.writeText(selection).catch((err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Chat, Message } from '@/api/types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
@@ -142,13 +142,26 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD_PX = 150;
|
||||
|
||||
export function MessageList({ messages, sessionChats }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
|
||||
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
isNearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
if (isNearBottomRef.current) {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
@@ -160,7 +173,7 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
|
||||
@@ -29,13 +29,13 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
49
apps/web/src/components/PermissionCard.tsx
Normal file
49
apps/web/src/components/PermissionCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import type { PermissionPrompt } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
prompt: PermissionPrompt;
|
||||
onRespond: (optionId: string | null) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
||||
return (
|
||||
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground">Permission required</p>
|
||||
{prompt.toolTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{prompt.toolTitle}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{prompt.options.map((opt) => (
|
||||
<button
|
||||
key={opt.optionId}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onRespond(opt.optionId)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-2.5 py-1 text-xs hover:bg-accent',
|
||||
'max-md:min-h-[44px] disabled:opacity-40',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onRespond(null)}
|
||||
className="rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive hover:bg-destructive/10 max-md:min-h-[44px] disabled:opacity-40"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -26,6 +26,7 @@ import { useViewport } from '@/hooks/useViewport';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import type { SidebarProject } from '@/api/types';
|
||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||
import { isCoderSessionName } from '@/lib/coder-session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EXPANDED_KEY = 'boocode.sidebar.expanded';
|
||||
@@ -382,7 +383,11 @@ export function ProjectSidebar() {
|
||||
to={`/session/${s.id}`}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
||||
{isCoderSessionName(s.name) ? (
|
||||
<Code className="size-3.5 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
||||
)}
|
||||
<span className="truncate flex-1" title={s.name}>{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{relTime(s.updated_at)}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Skill } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
skills: Skill[];
|
||||
// v1.12 CP7.5: was `anchorRect: {top, left}` (snapshot at open time). Now a
|
||||
// live ref so the dropdown can re-stat the input on visualViewport events —
|
||||
// critical on iOS where the keyboard shifts the visual viewport and the
|
||||
// dropdown would otherwise sit in the wrong place (often hidden).
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// max-h-[320px] on the popover — use as the height budget for above/below
|
||||
// fit decisions. Slightly under-estimates when the list is short, but the
|
||||
// only consequence is we sometimes flip below when we'd fit above; no UX
|
||||
// breakage either way.
|
||||
const DROPDOWN_HEIGHT_BUDGET = 320;
|
||||
|
||||
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
|
||||
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
|
||||
// `Command` (cmdk) isn't installed in this project; per the addendum we use
|
||||
// a plain div + Tailwind instead of pulling a new primitive autonomously.
|
||||
//
|
||||
// v1.12 CP7.5: portalled to document.body (escapes transformed/will-change
|
||||
// ancestor stacking contexts that hid the popover inside ChatInput on iOS)
|
||||
// + visualViewport-aware positioning (handles keyboard open/close + the iOS
|
||||
// "shift layout to keep input visible" auto-scroll).
|
||||
|
||||
// Case-insensitive prefix match on `name` only. Description is display-only
|
||||
// in v1 (substring search across description is deferred to a polish batch).
|
||||
function filterByPrefix(skills: Skill[], query: string): Skill[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q
|
||||
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
|
||||
: skills;
|
||||
// Stable alphabetical ordering matches the server's cache order (skills.ts
|
||||
// sorts on name asc) but we re-sort here so a stale client cache doesn't
|
||||
// surprise the user.
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SkillSlashCommand({ query, skills, inputRef, onSelect, onClose }: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
|
||||
|
||||
// Anchor + viewport tracking. `rect` is the input's bounding rect in layout
|
||||
// viewport coords. `vvTick` forces a re-render whenever visualViewport
|
||||
// changes even if the rect itself didn't (e.g. user scrolled the visual
|
||||
// viewport without the input moving in layout space).
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
);
|
||||
const [vvTick, setVvTick] = useState(0);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
// v1.12 CP7.5: recalc on viewport changes. iOS Safari fires
|
||||
// visualViewport.resize when the soft keyboard opens/closes; .scroll fires
|
||||
// when the page is shifted to keep the focused input visible above the
|
||||
// keyboard. Both events should trigger a position recompute.
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
setRect(inputRef.current?.getBoundingClientRect() ?? null);
|
||||
setVvTick((t) => t + 1);
|
||||
}
|
||||
recalc();
|
||||
const vv = window.visualViewport;
|
||||
vv?.addEventListener('resize', recalc);
|
||||
vv?.addEventListener('scroll', recalc);
|
||||
window.addEventListener('resize', recalc);
|
||||
return () => {
|
||||
vv?.removeEventListener('resize', recalc);
|
||||
vv?.removeEventListener('scroll', recalc);
|
||||
window.removeEventListener('resize', recalc);
|
||||
};
|
||||
}, [inputRef]);
|
||||
|
||||
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
|
||||
// textarea reach the popover even though focus stays in the textarea.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
// v1.12 CP7.5: visualViewport-corrected positioning. getBoundingClientRect
|
||||
// returns layout-viewport coords; iOS Safari's `position: fixed` positions
|
||||
// relative to the layout viewport too — but the visible area can be offset
|
||||
// (vv.offsetTop/offsetLeft) when iOS scrolls the input above the keyboard.
|
||||
// Subtracting the vv offsets keeps the dropdown locked to the input's
|
||||
// visual position. vvTick is in the dep list to force recompute on
|
||||
// visualViewport events even when the rect itself didn't change.
|
||||
//
|
||||
// Default: position above the input (matches original UX). Flip below if
|
||||
// above doesn't fit (input too close to top of visible viewport). When
|
||||
// below would overlap the keyboard, cap top so the dropdown stays visible.
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
if (!rect) return { display: 'none' };
|
||||
const vv = window.visualViewport;
|
||||
const vvOffsetTop = vv?.offsetTop ?? 0;
|
||||
const vvOffsetLeft = vv?.offsetLeft ?? 0;
|
||||
const vvHeight = vv?.height ?? window.innerHeight;
|
||||
|
||||
const anchorTop = rect.top - vvOffsetTop;
|
||||
const anchorBottom = rect.bottom - vvOffsetTop;
|
||||
const left = rect.left - vvOffsetLeft;
|
||||
|
||||
const fitsAbove = anchorTop >= DROPDOWN_HEIGHT_BUDGET;
|
||||
if (fitsAbove) {
|
||||
// translate(-100%) on Y so the dropdown grows upward from anchorTop.
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: anchorTop,
|
||||
left,
|
||||
transform: 'translateY(-100%)',
|
||||
};
|
||||
}
|
||||
// Render below; clamp so the bottom edge stays inside the visible viewport.
|
||||
const maxTop = Math.max(0, vvHeight - DROPDOWN_HEIGHT_BUDGET);
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: Math.min(anchorBottom, maxTop),
|
||||
left,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((skill, i) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
// mousedown not click — click runs after blur/focus shuffles which
|
||||
// can race with the textarea's onBlur close path.
|
||||
e.preventDefault();
|
||||
onSelect(skill.name);
|
||||
}}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// v1.12 CP7.5: portal to document.body to escape ChatInput's stacking
|
||||
// context. The original render-in-place rendered the dropdown inside the
|
||||
// composer's transformed/will-change ancestor tree, which on iOS Safari +
|
||||
// Vivaldi caused the popover to either disappear or sit at z-index 0
|
||||
// behind the autofill toolbar. document.body has no transform ancestor.
|
||||
return createPortal(popover, document.body);
|
||||
}
|
||||
181
apps/web/src/components/SlashCommandPicker.tsx
Normal file
181
apps/web/src/components/SlashCommandPicker.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SlashCommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
items: SlashCommandItem[];
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (name: string) => void;
|
||||
onClose: () => void;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
const DROPDOWN_HEIGHT_BUDGET = 320;
|
||||
|
||||
function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items;
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SlashCommandPicker({
|
||||
query,
|
||||
items,
|
||||
inputRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
emptyLabel = 'No commands available',
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
||||
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
);
|
||||
const [vvTick, setVvTick] = useState(0);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
setRect(inputRef.current?.getBoundingClientRect() ?? null);
|
||||
setVvTick((t) => t + 1);
|
||||
}
|
||||
recalc();
|
||||
const vv = window.visualViewport;
|
||||
vv?.addEventListener('resize', recalc);
|
||||
vv?.addEventListener('scroll', recalc);
|
||||
window.addEventListener('resize', recalc);
|
||||
return () => {
|
||||
vv?.removeEventListener('resize', recalc);
|
||||
vv?.removeEventListener('scroll', recalc);
|
||||
window.removeEventListener('resize', recalc);
|
||||
};
|
||||
}, [inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
if (!rect) return { display: 'none' };
|
||||
const vv = window.visualViewport;
|
||||
const vvOffsetTop = vv?.offsetTop ?? 0;
|
||||
const vvHeight = vv?.height ?? window.innerHeight;
|
||||
// Visible region in layout-viewport coords (what position:fixed uses)
|
||||
const visibleTop = vvOffsetTop;
|
||||
const visibleBottom = vvOffsetTop + vvHeight;
|
||||
|
||||
const spaceAbove = rect.top - visibleTop;
|
||||
const spaceBelow = visibleBottom - rect.bottom;
|
||||
|
||||
if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) {
|
||||
// Place above: clamp to visible top
|
||||
const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET);
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: popupTop,
|
||||
left: rect.left,
|
||||
maxHeight: rect.top - popupTop,
|
||||
};
|
||||
}
|
||||
// Place below: clamp to visible bottom
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No command starts with "/${query}"` : emptyLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(popover, document.body);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
||||
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { terminalsRegistry } from '@/lib/events';
|
||||
@@ -34,6 +34,8 @@ interface Props {
|
||||
// v1.9: passed through to SettingsPane when one is mounted in the grid.
|
||||
session: Session;
|
||||
project: Project | null;
|
||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -45,6 +47,7 @@ export function Workspace({
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
onAddPane,
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
@@ -59,6 +62,7 @@ export function Workspace({
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
removePane,
|
||||
isPaneChatPending,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
@@ -134,44 +138,11 @@ export function Workspace({
|
||||
return out;
|
||||
}, [panes]);
|
||||
|
||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
// v1.9: settings panes excluded from the MAX cap (decision c).
|
||||
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
|
||||
'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
Split
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||
<Code size={14} /> Coder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
|
||||
pill (MobileTabSwitcher) is the mobile pane switcher. */}
|
||||
|
||||
<div
|
||||
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
||||
style={
|
||||
@@ -185,6 +156,7 @@ export function Workspace({
|
||||
{panes.map((pane, idx) => {
|
||||
const isSettings = pane.kind === 'settings';
|
||||
const isTerminal = pane.kind === 'terminal';
|
||||
const isCoder = pane.kind === 'coder';
|
||||
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
@@ -197,9 +169,8 @@ export function Workspace({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||
const isChromeless = isSettings || isTerminal || isArtifact;
|
||||
// Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
|
||||
const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -233,13 +204,66 @@ export function Workspace({
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onAddPane={(kind) => {
|
||||
if (kind === 'chat') void createChat(idx);
|
||||
else addSplitPane(kind);
|
||||
else onAddPane(kind);
|
||||
}}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isCoder && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<Code size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||
aria-label="New pane"
|
||||
title="New pane"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
||||
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
|
||||
)}
|
||||
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
{panes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePane(idx);
|
||||
}}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||
aria-label="Close BooCode pane"
|
||||
title="Close BooCode pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isTerminal && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<Terminal size={12} className="text-muted-foreground" />
|
||||
@@ -259,14 +283,14 @@ export function Workspace({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -323,7 +347,18 @@ export function Workspace({
|
||||
active={idx === activePaneIdx}
|
||||
/>
|
||||
) : pane.kind === 'coder' ? (
|
||||
<CoderPane sessionId={sessionId} />
|
||||
<CoderPane
|
||||
sessionId={sessionId}
|
||||
paneId={pane.id}
|
||||
chatId={activePaneChatId(pane)}
|
||||
chatPending={isPaneChatPending(pane.id)}
|
||||
projectPath={project?.path}
|
||||
onConnectedChange={(connected) =>
|
||||
setCoderConnected((prev) =>
|
||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||
<MarkdownArtifactPane
|
||||
chatId={pane.markdown_artifact_state.chat_id}
|
||||
|
||||
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal file
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
||||
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
||||
|
||||
export interface CoderMessageWire {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: CoderToolCallWire[];
|
||||
}
|
||||
|
||||
export interface CoderToolMessageWire {
|
||||
id: string;
|
||||
role: 'tool';
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
|
||||
|
||||
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
|
||||
return m.role === 'tool';
|
||||
}
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'message'; message: CoderMessageWire }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||
|
||||
const GROUP_THRESHOLD = 3;
|
||||
const SCROLL_THRESHOLD_PX = 150;
|
||||
|
||||
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
|
||||
const items: RenderItem[] = [];
|
||||
const runsByCallId = new Map<string, ToolRun>();
|
||||
|
||||
for (const m of messages) {
|
||||
if (isToolMessage(m)) {
|
||||
const run = runsByCallId.get(m.tool_results.tool_call_id);
|
||||
if (run) {
|
||||
run.result = {
|
||||
tool_call_id: m.tool_results.tool_call_id,
|
||||
output: m.tool_results.output,
|
||||
truncated: m.tool_results.truncated ?? false,
|
||||
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === 'user' || m.role === 'system') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
|
||||
const hasText = m.content.trim().length > 0;
|
||||
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
|
||||
// External agents persist tool calls + final answer on one row. Render tools
|
||||
// before the answer text so the timeline matches BooChat (tools, then reply).
|
||||
const externalCombined = hasToolCalls && (hasText || hasReasoning);
|
||||
|
||||
if (externalCombined) {
|
||||
if (hasReasoning) {
|
||||
items.push({
|
||||
kind: 'message',
|
||||
message: { ...m, content: '', reasoning_text: m.reasoning_text },
|
||||
});
|
||||
}
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run = wireToolCallToRun(tc);
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
}
|
||||
if (hasText || m.status === 'streaming') {
|
||||
items.push({
|
||||
kind: 'message',
|
||||
message: { ...m, reasoning_text: undefined },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Native inference: separate assistant rows per step — mirror MessageList.
|
||||
if (hasText || hasReasoning || m.status === 'streaming') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run = wireToolCallToRun(tc);
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
||||
const out: RenderItem[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const item = items[i]!;
|
||||
if (item.kind !== 'tool_run') {
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < items.length &&
|
||||
items[j]!.kind === 'tool_run' &&
|
||||
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
||||
if (run.length >= GROUP_THRESHOLD) {
|
||||
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
|
||||
} else {
|
||||
for (const r of run) out.push(r);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
|
||||
const isUser = message.role === 'user';
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const hasText = message.content.trim().length > 0;
|
||||
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasReasoning && (
|
||||
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
|
||||
{message.reasoning_text}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{(hasText || (isStreaming && !hasReasoning)) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasText ? <MarkdownRenderer content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.status === 'failed' && (
|
||||
<div className="text-xs text-destructive">message failed</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: CoderTimelineWire[];
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function CoderMessageList({ messages, footer }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
|
||||
const renderItems = useMemo(
|
||||
() => groupToolRuns(flattenCoderMessages(messages)),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isNearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNearBottomRef.current) {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollRef} onScroll={handleScroll}>
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
return <CoderTextBubble key={item.message.id} message={item.message} />;
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
})}
|
||||
{footer}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside
|
||||
// BooChat's multi-pane workspace.
|
||||
// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
|
||||
//
|
||||
// Architecture:
|
||||
// - REST calls go through /api/coder/* which BooChat's server proxies to
|
||||
// the boocoder container at http://boocoder:3000/api/*
|
||||
// - WS connects directly to the boocoder container at :9502 (same Tailscale
|
||||
// network, no CORS for WebSocket). In dev, the Vite proxy handles it.
|
||||
// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
import { toast } from 'sonner';
|
||||
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { mergeWireToolCall } from '@/lib/coder-tools';
|
||||
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -21,16 +27,26 @@ interface CoderMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
tool_results?: {
|
||||
}
|
||||
|
||||
interface CoderToolMessage {
|
||||
id: string;
|
||||
role: 'tool';
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
|
||||
|
||||
interface PendingChange {
|
||||
id: string;
|
||||
file_path: string;
|
||||
@@ -42,24 +58,106 @@ interface PendingChange {
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
chatId?: string;
|
||||
chatPending?: boolean;
|
||||
projectPath?: string;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
interface WsHandlers {
|
||||
onPermissionRequested?: (prompt: PermissionPrompt) => void;
|
||||
onPermissionResolved?: (taskId: string) => void;
|
||||
onAssistantComplete?: () => void;
|
||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string) {
|
||||
const [messages, setMessages] = useState<CoderMessage[]>([]);
|
||||
type RawCoderMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
chat_id?: string;
|
||||
content?: string | null;
|
||||
status?: string | null;
|
||||
reasoning_text?: string;
|
||||
reasoning_parts?: Array<{ text?: string }> | null;
|
||||
tool_results?: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
tool_calls?: Array<
|
||||
| { id: string; name: string; args?: Record<string, unknown> }
|
||||
| { id: string; function: { name: string; arguments: string } }
|
||||
> | null;
|
||||
};
|
||||
|
||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||
if (raw.role === 'tool') {
|
||||
if (!raw.tool_results?.tool_call_id) return null;
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'tool',
|
||||
tool_results: raw.tool_results,
|
||||
};
|
||||
}
|
||||
if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null;
|
||||
const tool_calls = raw.tool_calls?.map((tc) => {
|
||||
if ('function' in tc) {
|
||||
return { id: tc.id, function: tc.function };
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const reasoning_text =
|
||||
raw.reasoning_text ??
|
||||
raw.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
||||
'';
|
||||
return {
|
||||
id: raw.id,
|
||||
role: raw.role as CoderMessage['role'],
|
||||
content: raw.content ?? '',
|
||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||
...(reasoning_text ? { reasoning_text } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) {
|
||||
const [messages, setMessages] = useState<CoderTimelineMessage[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.coder
|
||||
.listMessages(sessionId, chatId)
|
||||
.then((rows) =>
|
||||
setMessages(
|
||||
rows
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
),
|
||||
)
|
||||
.catch(() => {/* boocoder may be down */});
|
||||
}, [sessionId, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch existing messages on mount
|
||||
fetch(`/api/coder/sessions/${sessionId}/messages`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: CoderMessage[]) => setMessages(data))
|
||||
.catch(() => {/* noop — coder backend may not be running */});
|
||||
}, [sessionId]);
|
||||
void loadMessages();
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
// WS connects to the coder backend. In production, this goes through the
|
||||
@@ -76,38 +174,137 @@ function useCoderMessages(sessionId: string) {
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(ev.data as string);
|
||||
if (frame.type === 'message_started') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' },
|
||||
]);
|
||||
const scopedChatId = chatIdRef.current;
|
||||
if (
|
||||
scopedChatId &&
|
||||
frame.chat_id &&
|
||||
frame.chat_id !== scopedChatId &&
|
||||
frame.type !== 'snapshot'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
|
||||
const rawMessages = (frame.messages as RawCoderMessage[]).filter(
|
||||
(m) => !scopedChatId || m.chat_id === scopedChatId,
|
||||
);
|
||||
setMessages(
|
||||
rawMessages
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
);
|
||||
} else if (frame.type === 'message_started') {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === frame.message_id)) return prev;
|
||||
const role = frame.role ?? 'assistant';
|
||||
const tempIdx =
|
||||
role === 'user'
|
||||
? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user')
|
||||
: -1;
|
||||
if (tempIdx >= 0) {
|
||||
return prev.map((m, i) =>
|
||||
i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: frame.message_id, role, content: '', status: 'streaming' },
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? { ...m, content: m.content + (frame.content ?? '') }
|
||||
: m
|
||||
)
|
||||
prev.map((m) => {
|
||||
if (m.id !== frame.message_id || m.role === 'tool') return m;
|
||||
const chunk = frame.content ?? '';
|
||||
if (m.role === 'user') {
|
||||
return { ...m, content: chunk || m.content };
|
||||
}
|
||||
return { ...m, content: m.content + chunk };
|
||||
}),
|
||||
);
|
||||
} else if (frame.type === 'message_complete') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, status: 'complete' } : m
|
||||
)
|
||||
);
|
||||
setMessages((prev) => {
|
||||
const completed = prev.find(
|
||||
(m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant',
|
||||
);
|
||||
const next = prev.map((m) =>
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? { ...m, status: 'complete' as const }
|
||||
: m,
|
||||
);
|
||||
if (completed) {
|
||||
queueMicrotask(() => handlersRef.current.onAssistantComplete?.());
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (frame.type === 'tool_call') {
|
||||
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
|
||||
if (tc?.id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role !== 'assistant' || m.id !== frame.message_id
|
||||
? m
|
||||
: { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) },
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (frame.type === 'tool_result') {
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((m) => m.id === frame.tool_message_id);
|
||||
if (exists) {
|
||||
return prev.map((m) =>
|
||||
m.role === 'tool' && m.id === frame.tool_message_id
|
||||
? {
|
||||
...m,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
}
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: frame.tool_message_id,
|
||||
role: 'tool' as const,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'reasoning_delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? {
|
||||
...m,
|
||||
tool_calls: [
|
||||
...(m.tool_calls ?? []),
|
||||
{ id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } },
|
||||
],
|
||||
}
|
||||
: m
|
||||
)
|
||||
m.id === frame.message_id && m.role === 'assistant'
|
||||
? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} else if (frame.type === 'permission_requested') {
|
||||
handlersRef.current.onPermissionRequested?.({
|
||||
taskId: frame.task_id,
|
||||
toolTitle: frame.tool_title,
|
||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||
optionId: o.option_id,
|
||||
label: o.label,
|
||||
})),
|
||||
});
|
||||
} else if (frame.type === 'permission_resolved') {
|
||||
handlersRef.current.onPermissionResolved?.(frame.task_id);
|
||||
} else if (frame.type === 'agent_commands') {
|
||||
handlersRef.current.onAgentCommands?.(
|
||||
frame.task_id,
|
||||
(frame.commands ?? []).map((c: { name: string; description?: string }) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -121,7 +318,11 @@ function useCoderMessages(sessionId: string) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { messages, setMessages, connected };
|
||||
useEffect(() => {
|
||||
handlersRef.current.onConnectedChange?.(connected);
|
||||
}, [connected]);
|
||||
|
||||
return { messages, setMessages, connected, loadMessages };
|
||||
}
|
||||
|
||||
function usePendingChanges(sessionId: string) {
|
||||
@@ -164,48 +365,6 @@ function usePendingChanges(sessionId: string) {
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CoderMessageBubble({ message }: { message: CoderMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1 px-3 py-2', isUser ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 max-w-[85%] text-sm',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2 space-y-1">
|
||||
{message.tool_calls.map((tc) => (
|
||||
<div key={tc.id} className="text-xs font-mono text-muted-foreground">
|
||||
<span className="text-primary/70">{tc.function.name}</span>
|
||||
{tc.function.arguments && (
|
||||
<span className="ml-1 opacity-60">
|
||||
({tc.function.arguments.slice(0, 80)}
|
||||
{tc.function.arguments.length > 80 ? '...' : ''})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === 'streaming' && (
|
||||
<span className="inline-block w-2 h-4 bg-current opacity-60 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
changes,
|
||||
loading,
|
||||
@@ -295,102 +454,272 @@ function DiffPanel({
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CoderPane({ sessionId }: Props) {
|
||||
const { messages, setMessages, connected } = useCoderMessages(sessionId);
|
||||
export function CoderPane({
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
chatPending = false,
|
||||
projectPath,
|
||||
onConnectedChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
model: '',
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
|
||||
const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
|
||||
const { skills } = useSkills();
|
||||
const [slashState, setSlashState] = useState<{ query: string } | null>(null);
|
||||
|
||||
const displayedCommands = useMemo(() => {
|
||||
const base =
|
||||
agentConfig.provider === 'boocode'
|
||||
? skills.map((s) => ({ name: s.name, description: s.description }))
|
||||
: providerCommands;
|
||||
return mergeCommandsByName(base, liveTaskCommands);
|
||||
}, [agentConfig.provider, skills, providerCommands, liveTaskCommands]);
|
||||
|
||||
const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
|
||||
const commandsByName = useMemo(
|
||||
() => new Set(displayedCommands.map((c) => c.name)),
|
||||
[displayedCommands],
|
||||
);
|
||||
|
||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||
onConnectedChange,
|
||||
onPermissionRequested: (prompt) => {
|
||||
setActiveTaskId(prompt.taskId);
|
||||
setPermissionPrompt(prompt);
|
||||
},
|
||||
onPermissionResolved: (taskId) => {
|
||||
if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) {
|
||||
setPermissionPrompt(null);
|
||||
}
|
||||
},
|
||||
onAssistantComplete: () => {
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
},
|
||||
onAgentCommands: (_taskId, commands) => {
|
||||
setLiveTaskCommands(commands);
|
||||
},
|
||||
});
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Refresh pending changes when a message_complete arrives
|
||||
useEffect(() => {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') {
|
||||
const lastAssistant = [...messages].reverse().find(
|
||||
(m): m is CoderMessage => m.role === 'assistant',
|
||||
);
|
||||
if (lastAssistant?.status === 'complete') {
|
||||
refresh();
|
||||
}
|
||||
}, [messages, refresh]);
|
||||
|
||||
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
|
||||
useEffect(() => {
|
||||
if (!activeTaskId || connected) return;
|
||||
const interval = setInterval(() => {
|
||||
if (!permissionPrompt) {
|
||||
void api.coder
|
||||
.getTaskPermission(activeTaskId)
|
||||
.then((prompt) => {
|
||||
setPermissionPrompt({
|
||||
taskId: prompt.taskId,
|
||||
toolTitle: prompt.toolTitle,
|
||||
options: prompt.options,
|
||||
});
|
||||
})
|
||||
.catch(() => {/* no pending permission */});
|
||||
}
|
||||
void api.coder
|
||||
.getTaskCommands(activeTaskId)
|
||||
.then((res) => setLiveTaskCommands(res.commands))
|
||||
.catch(() => {/* not cached yet */});
|
||||
void api.coder
|
||||
.getTask(activeTaskId)
|
||||
.then((task) => {
|
||||
if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') {
|
||||
return;
|
||||
}
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
void loadMessages();
|
||||
})
|
||||
.catch(() => {/* task gone */});
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTaskId, connected, permissionPrompt, loadMessages]);
|
||||
|
||||
const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => {
|
||||
setProviderCommands(commands);
|
||||
}, []);
|
||||
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
||||
if (!permissionPrompt) return;
|
||||
setPermissionBusy(true);
|
||||
try {
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
||||
setPermissionPrompt(null);
|
||||
} finally {
|
||||
setPermissionBusy(false);
|
||||
}
|
||||
}, [permissionPrompt]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
if (!text || sending || !chatId) return;
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
const parsed = parseSlashInput(text);
|
||||
if (parsed) {
|
||||
const { cmdName, args } = parsed;
|
||||
if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) {
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
await api.coder.skillInvoke(
|
||||
sessionId,
|
||||
paneId,
|
||||
cmdName,
|
||||
args.length > 0 ? args : null,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!commandsByName.has(cmdName)) {
|
||||
// Unknown slash — fall through and send as literal text.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
|
||||
// Optimistic user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ content: text }),
|
||||
const data = await api.coder.sendMessage(sessionId, {
|
||||
content: text,
|
||||
pane_id: paneId,
|
||||
chat_id: chatId,
|
||||
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
|
||||
model: agentConfig.model || undefined,
|
||||
mode_id: agentConfig.modeId ?? undefined,
|
||||
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Replace temp message with real one if server returned it
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m)
|
||||
);
|
||||
}
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The WS will bring the real messages; optimistic is good enough
|
||||
if (data.task_id) {
|
||||
setActiveTaskId(data.task_id);
|
||||
} else {
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, sessionId, setMessages]);
|
||||
}, [
|
||||
input,
|
||||
sending,
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
agentConfig,
|
||||
skillsByName,
|
||||
commandsByName,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
const handleSlashSelect = useCallback((name: string) => {
|
||||
const next = `/${name} `;
|
||||
setInput(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = inputRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInput(newValue);
|
||||
if (isSlashCommandToken(newValue)) {
|
||||
setSlashState({ query: slashQuery(newValue) });
|
||||
} else {
|
||||
setSlashState(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (slashState) return;
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
[handleSend, slashState]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
|
||||
<Code size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">BooCoder</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full ml-auto',
|
||||
connected ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-sm text-muted-foreground gap-2">
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-2">
|
||||
<Code size={32} className="opacity-40" />
|
||||
<p>Send a message to start coding</p>
|
||||
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{messages.map((msg) => (
|
||||
<CoderMessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<CoderMessageList
|
||||
messages={messages as CoderTimelineWire[]}
|
||||
footer={
|
||||
activeTaskId && !permissionPrompt && sending === false ? (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{permissionPrompt && (
|
||||
<PermissionCard
|
||||
prompt={permissionPrompt}
|
||||
onRespond={(id) => void handlePermissionRespond(id)}
|
||||
busy={permissionBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Diff panel — only shows when there are pending changes */}
|
||||
{changes.filter((c) => c.status === 'pending').length > 0 && (
|
||||
<div className="h-48 shrink-0">
|
||||
@@ -404,28 +733,46 @@ export function CoderPane({ sessionId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 border-t border-border p-2">
|
||||
{/* Composer + input */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask BooCoder to write code..."
|
||||
placeholder="Type / for commands…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || sending}
|
||||
disabled={!input.trim() || sending || !chatId || chatPending}
|
||||
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{slashState && (
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
items={displayedCommands}
|
||||
inputRef={inputRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
49
apps/web/src/hooks/useProviderSnapshot.ts
Normal file
49
apps/web/src/hooks/useProviderSnapshot.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { ProviderSnapshotEntry } from '@/api/types';
|
||||
|
||||
let cached: ProviderSnapshotEntry[] | null = null;
|
||||
let inflight: Promise<ProviderSnapshotEntry[]> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
function getSnapshot(): ProviderSnapshotEntry[] | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
async function doFetch(cwd?: string): Promise<ProviderSnapshotEntry[]> {
|
||||
const data = await api.coder.snapshot(cwd);
|
||||
cached = data;
|
||||
inflight = null;
|
||||
notify();
|
||||
return data;
|
||||
}
|
||||
|
||||
function ensureLoaded(cwd?: string): void {
|
||||
if (cached || inflight) return;
|
||||
inflight = doFetch(cwd).catch((err) => {
|
||||
inflight = null;
|
||||
console.error('provider snapshot fetch failed:', err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshProviderSnapshot(cwd?: string): Promise<ProviderSnapshotEntry[]> {
|
||||
cached = null;
|
||||
inflight = null;
|
||||
return doFetch(cwd);
|
||||
}
|
||||
|
||||
export function useProviderSnapshot(cwd?: string): ProviderSnapshotEntry[] | null {
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot);
|
||||
useEffect(() => { ensureLoaded(cwd); }, [cwd]);
|
||||
return entries;
|
||||
}
|
||||
@@ -48,9 +48,14 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
return { ...state, messages: [...state.messages, newMsg] };
|
||||
}
|
||||
case 'delta': {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m
|
||||
);
|
||||
const next = state.messages.map((m) => {
|
||||
if (m.id !== frame.message_id) return m;
|
||||
const chunk = frame.content ?? '';
|
||||
if (m.role === 'user') {
|
||||
return { ...m, content: chunk || m.content };
|
||||
}
|
||||
return { ...m, content: m.content + chunk };
|
||||
});
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'tool_call': {
|
||||
|
||||
@@ -32,19 +32,19 @@ function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
|
||||
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
|
||||
// persist in localStorage along with chat panes so a refresh resumes the
|
||||
// same tmux window via the idempotent start endpoint.
|
||||
function terminalPane(id: string = generateId()): WorkspacePane {
|
||||
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||
return kind === 'coder' ? 'BooCoder' : 'Terminal';
|
||||
}
|
||||
|
||||
// v2.0.0: coder pane — renders the BooCoder interface (chat + diff panel).
|
||||
// Like terminal panes, carries no chats — the CoderPane component manages
|
||||
// its own session/messages via the /api/coder proxy.
|
||||
function coderPane(id: string = generateId()): WorkspacePane {
|
||||
return { id, kind: 'coder', chatIds: [], activeChatIdx: -1 };
|
||||
function scopedPane(id: string, kind: 'coder' | 'terminal', chatId: string): WorkspacePane {
|
||||
return { id, kind, chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
/** Active chat id for a pane row (chat / coder / terminal). */
|
||||
export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
||||
const idx = pane.activeChatIdx ?? 0;
|
||||
if (idx >= 0 && pane.chatIds?.[idx]) return pane.chatIds[idx];
|
||||
return pane.chatId;
|
||||
}
|
||||
|
||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||
@@ -79,8 +79,20 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
|
||||
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
||||
// page reload always returns to a clean workspace; the user re-opens via the
|
||||
// sidebar Settings button when needed.
|
||||
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
|
||||
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
|
||||
if ((pane.kind as string) === 'agent') {
|
||||
return { ...pane, kind: 'coder' };
|
||||
}
|
||||
return pane;
|
||||
}
|
||||
|
||||
function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
||||
return panes.map(normalizePaneKind);
|
||||
}
|
||||
|
||||
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
||||
return panes.filter((p) => p.kind !== 'settings');
|
||||
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
||||
}
|
||||
|
||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||
@@ -128,6 +140,8 @@ export interface UseWorkspacePanesResult {
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
validatePanes: (validChatIds: Set<string>) => void;
|
||||
/** True while a coder/terminal pane is waiting for its scoped chat row. */
|
||||
isPaneChatPending: (paneId: string) => boolean;
|
||||
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragLeave: () => void;
|
||||
@@ -149,6 +163,54 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
// Tracks the last value broadcast by another device (or this one's own
|
||||
// round-trip). If a PATCH would echo this exact payload, we skip the call.
|
||||
const lastRemoteJsonRef = useRef<string>('[]');
|
||||
const pendingPaneChatRef = useRef<Set<string>>(new Set());
|
||||
const [pendingPaneChatIds, setPendingPaneChatIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const markPaneChatPending = useCallback((paneId: string, pending: boolean) => {
|
||||
setPendingPaneChatIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (pending) next.add(paneId);
|
||||
else next.delete(paneId);
|
||||
pendingPaneChatRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attachChatToPane = useCallback(
|
||||
(paneId: string, chatId: string, kind: 'coder' | 'terminal') => {
|
||||
setPanes((prev) =>
|
||||
prev.map((p) => (p.id === paneId ? scopedPane(paneId, kind, chatId) : p)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const seedPaneChat = useCallback(
|
||||
async (paneId: string, kind: 'coder' | 'terminal') => {
|
||||
if (pendingPaneChatRef.current.has(paneId)) return;
|
||||
markPaneChatPending(paneId, true);
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
|
||||
attachChatToPane(paneId, chat.id, kind);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
|
||||
} finally {
|
||||
markPaneChatPending(paneId, false);
|
||||
}
|
||||
},
|
||||
[sessionId, attachChatToPane, markPaneChatPending],
|
||||
);
|
||||
|
||||
const seedEmptyScopedPanes = useCallback(
|
||||
(paneList: WorkspacePane[]) => {
|
||||
for (const pane of paneList) {
|
||||
if (pane.kind !== 'coder' && pane.kind !== 'terminal') continue;
|
||||
if ((pane.chatIds?.length ?? 0) > 0 || pane.chatId) continue;
|
||||
void seedPaneChat(pane.id, pane.kind);
|
||||
}
|
||||
},
|
||||
[seedPaneChat],
|
||||
);
|
||||
|
||||
// v1.12.1: hydrate from server on mount, then subscribe to remote updates.
|
||||
useEffect(() => {
|
||||
@@ -159,7 +221,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
if (cancelled) return;
|
||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
||||
? session.workspace_panes
|
||||
? normalizePanes(session.workspace_panes)
|
||||
: [];
|
||||
// One-time migration: if server is empty but legacy localStorage has
|
||||
// a layout, seed the server and delete the local key.
|
||||
@@ -180,12 +242,13 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
||||
setPanes(next);
|
||||
setActivePaneIdx(0);
|
||||
seedEmptyScopedPanes(next);
|
||||
} finally {
|
||||
if (!cancelled) hydratedRef.current = true;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
}, [sessionId, seedEmptyScopedPanes]);
|
||||
|
||||
// v1.12.1: live cross-device sync. Replace local state when another device
|
||||
// (or our own write echo) lands a session_workspace_updated frame.
|
||||
@@ -193,14 +256,17 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'session_workspace_updated') return;
|
||||
if (ev.session_id !== sessionId) return;
|
||||
const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [];
|
||||
const incoming = normalizePanes(
|
||||
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
|
||||
);
|
||||
const json = JSON.stringify(incoming);
|
||||
if (json === lastRemoteJsonRef.current) return;
|
||||
lastRemoteJsonRef.current = json;
|
||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||
});
|
||||
}, [sessionId]);
|
||||
}, [sessionId, seedEmptyScopedPanes]);
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
|
||||
// these per click. If a pane already exists for the same message_id, focus
|
||||
@@ -388,8 +454,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
|
||||
const showLandingPage = useCallback((paneIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const pane = prev[paneIdx];
|
||||
// Coder/terminal panes are not chat hosts — history button is chat-only.
|
||||
if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev;
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
||||
return next;
|
||||
});
|
||||
@@ -408,16 +476,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return prev;
|
||||
}
|
||||
const newPane =
|
||||
kind === 'terminal' ? terminalPane(newPaneId) :
|
||||
kind === 'coder' ? coderPane(newPaneId) :
|
||||
emptyPane(newPaneId);
|
||||
kind === 'terminal'
|
||||
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], activeChatIdx: -1 }
|
||||
: kind === 'coder'
|
||||
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], activeChatIdx: -1 }
|
||||
: emptyPane(newPaneId);
|
||||
const next = [...prev, newPane];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
success = true;
|
||||
if (kind === 'terminal' || kind === 'coder') {
|
||||
queueMicrotask(() => void seedPaneChat(newPaneId, kind));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return success ? newPaneId : null;
|
||||
}, []);
|
||||
}, [seedPaneChat]);
|
||||
|
||||
const toggleSettingsPane = useCallback(() => {
|
||||
setPanes((prev) => {
|
||||
@@ -476,19 +549,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const validatePanes = useCallback((validChatIds: Set<string>) => {
|
||||
setPanes((prev) => {
|
||||
const cleaned = prev.map((pane) => {
|
||||
if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane;
|
||||
const usesChat =
|
||||
pane.kind === 'chat' || pane.kind === 'coder' || pane.kind === 'terminal';
|
||||
if (!usesChat || pane.chatIds.length === 0) return pane;
|
||||
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
|
||||
if (nextIds.length === pane.chatIds.length) return pane;
|
||||
if (nextIds.length === 0) {
|
||||
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
if (pane.kind === 'chat') {
|
||||
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
return { ...pane, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
|
||||
});
|
||||
const unchanged = cleaned.every((p, i) => p === prev[i]);
|
||||
return unchanged ? prev : cleaned;
|
||||
const next = unchanged ? prev : cleaned;
|
||||
if (!unchanged) {
|
||||
for (const pane of next) {
|
||||
if (pane.kind === 'coder' && !activePaneChatId(pane)) {
|
||||
queueMicrotask(() => void seedPaneChat(pane.id, 'coder'));
|
||||
} else if (pane.kind === 'terminal' && !activePaneChatId(pane)) {
|
||||
queueMicrotask(() => void seedPaneChat(pane.id, 'terminal'));
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [seedPaneChat]);
|
||||
|
||||
const isPaneChatPending = useCallback(
|
||||
(paneId: string) => pendingPaneChatIds.has(paneId),
|
||||
[pendingPaneChatIds],
|
||||
);
|
||||
|
||||
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||
setPanes((prev) => prev.map((p) => {
|
||||
@@ -574,6 +667,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
isPaneChatPending,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
|
||||
11
apps/web/src/lib/apply-user-delta.ts
Normal file
11
apps/web/src/lib/apply-user-delta.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** User messages are inserted atomically — never stream-append like assistant deltas. */
|
||||
export function applyMessageDelta(
|
||||
role: 'user' | 'assistant' | 'system' | 'tool',
|
||||
existingContent: string,
|
||||
chunk: string,
|
||||
): string {
|
||||
if (role === 'user') {
|
||||
return chunk || existingContent;
|
||||
}
|
||||
return existingContent + chunk;
|
||||
}
|
||||
18
apps/web/src/lib/coder-session.ts
Normal file
18
apps/web/src/lib/coder-session.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Sessions created for BooCoder work (sidebar / project list icons). */
|
||||
export function isCoderSessionName(name: string | null | undefined): boolean {
|
||||
if (!name) return false;
|
||||
if (name === 'New BooCode') return true;
|
||||
if (name.startsWith('Task [')) return true;
|
||||
if (name.startsWith('Coder:')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Optimistic coder pane shell before scoped chat id arrives from the server. */
|
||||
export function defaultCoderWorkspacePane(id: string = crypto.randomUUID()) {
|
||||
return {
|
||||
id,
|
||||
kind: 'coder' as const,
|
||||
chatIds: [] as string[],
|
||||
activeChatIdx: -1,
|
||||
};
|
||||
}
|
||||
68
apps/web/src/lib/coder-tools.ts
Normal file
68
apps/web/src/lib/coder-tools.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
import type { ToolRun } from '@/components/ToolCallLine';
|
||||
|
||||
export interface AcpWireMeta {
|
||||
status?: 'running' | 'completed' | 'failed' | 'canceled';
|
||||
kind?: string | null;
|
||||
title?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CoderToolCallWire {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function wireToolCallToRun(wire: CoderToolCallWire): ToolRun {
|
||||
const args = parseArgs(wire.function.arguments);
|
||||
const acp = args._acp as AcpWireMeta | undefined;
|
||||
const { _acp: _ignored, ...rest } = args;
|
||||
const lifecycle = acp?.status ?? 'running';
|
||||
const call: ToolCall = {
|
||||
id: wire.id,
|
||||
name: wire.function.name,
|
||||
args: rest,
|
||||
};
|
||||
if (lifecycle === 'running') {
|
||||
return { call, result: null };
|
||||
}
|
||||
const result: ToolResult = {
|
||||
tool_call_id: wire.id,
|
||||
output: acp?.output ?? null,
|
||||
truncated: false,
|
||||
...(acp?.error ? { error: acp.error } : {}),
|
||||
};
|
||||
return { call, result };
|
||||
}
|
||||
|
||||
export function mergeWireToolCall(
|
||||
existing: CoderToolCallWire[] | undefined,
|
||||
incoming: { id: string; name: string; args: Record<string, unknown> },
|
||||
): CoderToolCallWire[] {
|
||||
const entry: CoderToolCallWire = {
|
||||
id: incoming.id,
|
||||
function: { name: incoming.name, arguments: JSON.stringify(incoming.args) },
|
||||
};
|
||||
const list = existing ?? [];
|
||||
const idx = list.findIndex((tc) => tc.id === incoming.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...list];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...list, entry];
|
||||
}
|
||||
|
||||
export function wireToolCallsToRuns(wires: CoderToolCallWire[] | undefined): ToolRun[] {
|
||||
return (wires ?? []).map(wireToolCallToRun);
|
||||
}
|
||||
29
apps/web/src/lib/slash-command.ts
Normal file
29
apps/web/src/lib/slash-command.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface SlashCommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** True while the user is still typing the command name after `/`. */
|
||||
export function isSlashCommandToken(value: string): boolean {
|
||||
return /^\/[^\s]*$/.test(value);
|
||||
}
|
||||
|
||||
export function slashQuery(value: string): string {
|
||||
return value.slice(1);
|
||||
}
|
||||
|
||||
export function parseSlashInput(text: string): { cmdName: string; args: string } | null {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
||||
}
|
||||
|
||||
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
|
||||
const byName = new Map<string, SlashCommandItem>();
|
||||
for (const list of lists) {
|
||||
for (const cmd of list) {
|
||||
byName.set(cmd.name, cmd);
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project as ProjectType, Session } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { isCoderSessionName } from '@/lib/coder-session';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
@@ -124,7 +125,11 @@ export function Project() {
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||
<Link to={`/session/${s.id}`} className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
|
||||
{isCoderSessionName(s.name) ? (
|
||||
<Code className="size-3.5 opacity-70 shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm">{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
|
||||
{s.model}
|
||||
|
||||
@@ -190,6 +190,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
[addSplitPane, isMobile, navigate, location.pathname, location.search],
|
||||
);
|
||||
|
||||
const activePaneKind = panes[activePaneIdx]?.kind;
|
||||
const showSessionModelPicker = activePaneKind !== 'coder';
|
||||
|
||||
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
|
||||
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
|
||||
// (which has its own copy binding for that combo); everything else fires
|
||||
@@ -351,7 +354,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session && (
|
||||
{session && showSessionModelPicker && (
|
||||
<ModelPicker
|
||||
value={session.model}
|
||||
onChange={async (model) => {
|
||||
@@ -449,7 +452,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
)}
|
||||
|
||||
<div className="ml-auto shrink-0">
|
||||
{session && (
|
||||
{session && showSessionModelPicker && (
|
||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||
<ModelPicker
|
||||
value={session.model}
|
||||
@@ -478,6 +481,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
chatsHook={chatsHook}
|
||||
session={session}
|
||||
project={project}
|
||||
onAddPane={addPaneAndSwitch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BooCode — External Code Review & Lift Inventory
|
||||
|
||||
Last updated: 2026-05-22
|
||||
Last updated: 2026-05-25
|
||||
|
||||
This document tracks every open source repo BooCode references or lifts code from. Pin this so we don't lose attribution and don't re-evaluate the same projects twice.
|
||||
|
||||
@@ -8,7 +8,7 @@ BooCode is personal/single-user — license compatibility is non-blocking, but t
|
||||
|
||||
> **Companion doc:** `boocode_roadmap.md` is the canonical source for shipping state, version ordering, and what's planned vs. shipped. This document is the canonical source for *why* each external repo earned its row. Reconcile shipping state via the roadmap when in doubt.
|
||||
>
|
||||
> **Shipped reality as of 2026-05-22** (per roadmap): v1.13.1 (`ac1a71f`), v1.13.3 (`a08d809`), v1.13.4 (`ec8593c`), v1.13.5 (`f8fc5db`), and v1.13.6 (`81d837c`) tagged. AI SDK v6 migration done. `message_parts` table + `messages_with_parts` view live with dual-write. `experimental_repairToolCall` wired. Alpha tool ordering shipped. Two-tier compaction prune + truncate.ts opaque-id retrieval shipped. v1.13.6 closed the Q3 reasoning-render gap in compaction (latent regression from v1.13.1-C). **v1.13.7 stability bundle** (`includeUsage:true` for usage capture, trim guards against `\n` content artifacts, payload filter for trailing empty/failed assistants, `BUDGET_NO_AGENT 15→30`) — fixes a v1.13.1-A latent regression where `result.usage` came back empty. v1.13.2 (legacy-column drop) **deferred behind v1.13.8–v1.13.12** as rollback insurance. v1.13.x cleanup line order is locked and **must not be folded**: v1.13.8 → v1.13.9 → v1.13.10 → v1.13.11 → v1.13.12 → v1.13.2. If anything in this catalog reads "planned" for a v1.11.x–v1.13.6 lift, check the lift catalog table at the bottom for the corrected status.
|
||||
> **Shipped reality as of 2026-05-25** (per roadmap + CHANGELOG): v1.13.x line closed. v2.0 BooCoder shipped (write tools, dispatcher, MCP server, CoderPane). v2.1.0 provider picker shipped — BooCoder on host systemd, direct spawn dispatch (SSH deprecated). Database name `boochat`, Docker service `boocode_db`. When this catalog reads "planned" for shipped work, check `CHANGELOG.md` and `boocode_roadmap.md`.
|
||||
|
||||
-----
|
||||
|
||||
@@ -18,16 +18,15 @@ Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo (ge
|
||||
|
||||
### Locked architecture decisions (2026-05-22, Sam confirmed)
|
||||
|
||||
1. **Monorepo with three apps, not three repos.** `/opt/boocode/apps/`:
|
||||
- `apps/web/` — existing React SPA (the current chat UI).
|
||||
- `apps/server/` — existing Fastify backend (the daemon).
|
||||
- **`apps/chat/`** — BooChat surface (read-only inference loop, current `9500`, the live thing at `code.indifferentketchup.com`).
|
||||
- **`apps/coder/`** — BooCoder surface (write-tool inference loop + external-CLI dispatch, port `9502`, `coder.indifferentketchup.com`, planned for v2.0).
|
||||
- **`apps/booterm/`** — BooTerm surface (PTY/terminal pane, **live since May 2026, port `9501`**). Node 20 Alpine + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (image includes `openssh-client` + `gosu`). `/api/term/health` shares the existing `boocode_db`. Built as part of Batch 10. Confirmed working as of 2026-05-19.
|
||||
- All three share the server package, the auth gate, the project registry, the task table, and the worktree manager.
|
||||
1. **Single shared database.** Rename current `boocode_db` → `boochat_db` when BooCoder lands. Three apps, one Postgres. Cross-surface joins are valuable: a BooCoder task can reference the BooChat conversation that originated it; a BooTerm session can be linked to the BooCoder task it's debugging. Separate databases would break this.
|
||||
1. **Monorepo with three surfaces, not three backend packages.** `/opt/boocode/apps/`:
|
||||
- `apps/web/` — React SPA (chat, coder, terminal panes).
|
||||
- `apps/server/` — Fastify backend for BooChat (inference, read-only tools, :9500 in Docker).
|
||||
- **`apps/coder/`** — BooCoder (write tools + external-CLI dispatch, port `9502`, `coder.indifferentketchup.com`, **shipped v2.0**, host systemd since v2.1.0).
|
||||
- **`apps/booterm/`** — BooTerm (PTY/terminal pane, **live since May 2026, port `9501`**). bookworm-slim + node-pty + tmux + xterm.js. Shares Postgres database `boochat`.
|
||||
- All three surfaces share the same Postgres, project registry, and task infrastructure.
|
||||
1. **Single shared database `boochat`.** Docker service name `boocode_db`. Three apps, one Postgres. Cross-surface joins are valuable: a BooCoder task can reference the BooChat conversation that originated it; a BooTerm session can be linked to the BooCoder task it's debugging.
|
||||
1. **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Container gets full RW access to `/opt`; the BooCoder write tools (`edit_file`, `create_file`, `delete_file`) enforce path scoping using the v1.15 permission wildcard ruleset (`apps/coder/services/path_guard.ts`). Per-project scoping is *policy*, not *mount*. Simpler, single mount, no Docker reconfig per project. Trade-off: a bug in path-guard logic is the only thing between BooCoder and writing outside `/opt/<project>/`. **Path-guard correctness is therefore the highest-priority test target for v2.0** — fuzz it, property-test it, run it through every traversal-attack pattern.
|
||||
1. **External CLI agents (`opencode`, `claude`, `goose`, `pi`) live on the host, NOT in the BooCoder container.** Sam's call: control. Host-installed agents inherit Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Tool versions update via Sam's normal `npm i -g` or `brew upgrade` flow. **BooCoder shells out via local-exec PTY** (`node-pty` with `cwd = /opt/<project>` and the host shell), or via SSH if Sam wants stricter isolation later. Container can be added back if a specific reason emerges (sandboxing a rogue agent, ABI mismatch, dependency conflict) but not pre-emptively.
|
||||
1. **External CLI agents (`opencode`, `claude`, `goose`, `pi`) live on the host.** BooCoder runs as `boocoder.service` on the host (v2.1.0+) and spawns agents directly via `install_path` — no SSH tunnel. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs.
|
||||
|
||||
### Three-surface execution model
|
||||
|
||||
@@ -35,8 +34,8 @@ Each surface has its own primary execution mode but shares the same underlying t
|
||||
|
||||
|Surface |Port |Execution mode |Tools |Write access |
|
||||
|----------------------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
|**BooChat** (`apps/chat`) |9500 |In-process inference loop |`view_file`, `list_dir`, `grep`, `find_files`, codecontext sidecar tools |None — `/opt` is read-only at the tool layer regardless of mount |
|
||||
|**BooCoder** (`apps/coder`) |9502 |**Two paths, same surface:** (a) in-process inference loop with native write tools + pending-changes queue, (b) PTY-dispatched external CLI (opencode/claude/goose/pi) in a per-task git worktree|All BooChat tools + `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` + `dispatch_external_agent`|Yes, gated through `pending_changes` table (nothing touches disk until `/apply`)|
|
||||
|**BooChat** (`apps/server` + `apps/web`)|9500 |In-process inference loop |`view_file`, `list_dir`, `grep`, `find_files`, codecontext sidecar tools |None — `/opt` is read-only at the tool layer regardless of mount |
|
||||
|**BooCoder** (`apps/coder`) |9502 |**Two paths, same surface:** (a) in-process inference loop with native write tools + pending-changes queue, (b) ACP/PTY-dispatched external CLI (opencode/claude/goose/qwen) in a per-task git worktree|All BooChat tools + `edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind` + external dispatch via `tasks`|Yes, gated through `pending_changes` table (nothing touches disk until `/apply`)|
|
||||
|**BooTerm** (`apps/booterm`)|**9501 (live)**|PTY to host shell via tmux, scoped to project cwd |Shell + SSH-out, no inference loop |Yes (it's a real terminal) |
|
||||
|
||||
**The "two paths, same surface" decision in BooCoder is the answer to last turn's "1 and 2 full featured" question.** The in-process loop (Option B / Answer B) handles interactive write work where Sam wants the pending-changes UI and native tool gating. The PTY dispatch (Option A / Answer A) handles parallel/dispatched/batch work where Sam wants to A/B different CLI agents against the same task in separate worktrees. The user picks per task via a `dispatch_external_agent(agent: 'opencode'|'claude'|'goose'|'pi', model: string, task: string, worktree: string)` tool the in-process loop can call, or via a UI dropdown at task creation.
|
||||
@@ -95,7 +94,7 @@ Paseo is "one interface for all your Claude Code, Codex, and OpenCode agents." 4
|
||||
**Core architectural choices, each a target for BooCode to reproduce:**
|
||||
|
||||
1. **Daemon + clients split.** A long-running local daemon owns agent process management; thin clients (CLI, desktop Electron, mobile Expo, web) connect over WebSocket. Daemon survives client disconnects. **BooCode equivalent:** the Fastify server is the daemon; the React SPA, the three surface tabs (chat/coder/term), and a new thin `boocode` CLI are all clients.
|
||||
1. **Six-package monorepo:** `server` (daemon), `app` (Expo iOS/Android/web), `cli`, `desktop` (Electron), `relay` (remote connectivity), `website`. **BooCode equivalent:** `apps/server` (Fastify, exists), `apps/web` (React, exists, hosts the chat/coder/term tabs), `apps/chat` + `apps/coder` + `apps/booterm` (the three surfaces — booterm already live on 9501 as of May 2026), `apps/cli` (new, thin client over WebSocket). `relay` is unnecessary — Sam's Tailscale + Caddy + Authelia stack at `code.indifferentketchup.com` already provides remote connectivity, mobile/desktop are PWA paths, no native shell needed yet.
|
||||
1. **Six-package monorepo:** `server` (daemon), `app` (Expo iOS/Android/web), `cli`, `desktop` (Electron), `relay` (remote connectivity), `website`. **BooCode equivalent:** `apps/server` (BooChat Fastify backend), `apps/web` (React SPA hosting all pane types), `apps/coder` + `apps/booterm` (BooCoder and BooTerm surfaces). `relay` is unnecessary — Sam's Tailscale + Caddy + Authelia stack already provides remote connectivity; mobile is PWA.
|
||||
1. **Process orchestration as the daemon's job.** Paseo spawns Claude Code / Codex / OpenCode as **child processes**, not API calls. Each agent runs with full local dev environment access. **BooCoder equivalent:** the dispatch worker (in `apps/server`) spawns `claude` / `opencode` / `goose` / `pi` via local-exec PTY on the **host**, captures stdout/stderr/exit-code into PostgreSQL stream tables, exposes WebSocket events to all three React surfaces.
|
||||
1. **CLI shape:**
|
||||
|
||||
@@ -413,8 +412,8 @@ Don't ship Phase 1 against AGPL/GPL code; build clean. Patterns are free; code i
|
||||
|
||||
- **v1.13.7 stability bundle uncovered two latent v1.13.1-A regressions (2026-05-22).** Investigation during the cosmetic-revert session surfaced: (1) `@ai-sdk/openai-compatible` defaults `includeUsage: false`, so `stream_options.include_usage` was never sent to llama-swap and `result.usage.inputTokens/outputTokens` resolved `undefined` — every assistant row had `tokens_used`/`ctx_used` NULL since v1.13.1-A shipped. One-line fix in `provider.ts`. (2) AI SDK v6 streaming occasionally emits a leading `\n` text-delta on tool-call-only turns; `content.length > 0` returned true for `"\n"`, producing an empty MessageBubble + ActionRow between every tool call. Fixed by trim guards in `MessageList.flatten` (`hasText`) and `MessageBubble` (`hasContent`). Plus: `buildMessagesPayload` now skips trailing empty/failed assistant rows (kills "Cannot have 2 or more assistant messages" rejections from the upstream), and `BUDGET_NO_AGENT` bumped 15→30 to match `BUDGET_READ_ONLY` (every tool today is read-only; the 15-cap was forward-looking). The class of bug is consistent: AI SDK v6 changes the streaming surface in ways that aren't caught by tsc or vitest — only production observability surfaces them. Argues for v1.13.11 WS-frame Zod schemas to catch the next round.
|
||||
- **MCP and ACP roles locked per surface (2026-05-22).** **BooChat = MCP client only**, read-only tool consumer. **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. Hard rule: BooChat MCP config must never enable a write-capable server (the read-only invariant overrides protocol convenience). BooCoder's ACP client role **replaces the raw-PTY dispatch plan for any agent that supports ACP** (opencode `opencode acp`, goose `goose acp`); claude/pi/smallcode stay on PTY fallback. The protocol pattern that justifies the full BooCoder matrix: ACP clients auto-forward their MCP `context_servers` to the dispatched agent (per goose docs) — one MCP config surface drives every dispatched agent. BooCoder MCP-server role exposes `boocoder.create_task`, `boocoder.list_pending_changes`, `boocoder.apply`, etc. so external opencode-in-Termius sessions become BooCoder-aware without going through BooCoder's UI. BooCoder ACP-agent role (`boocoder acp`) lets Zed/JetBrains/Avante.nvim drive BooCoder as their agent — outbound exposure, lowest priority of the four roles. **Reference materials**: anthropics `mcp-builder` skill (4-phase build workflow + 10-question eval framework), opencode MCP/ACP docs as JSON-schema reference, goose ACP docs for the `context_servers` auto-forward pattern, `agentclientprotocol.com` spec — but note remote ACP (HTTP/WS) is still WIP, BooCoder's ACP client must use stdio for v1.
|
||||
- **BooCode monorepo locked as 3-app structure (2026-05-22).** Same `/opt/boocode/` repo: `apps/chat/` (read-only, currently the live thing at 9500), `apps/coder/` (write tools + external CLI dispatch, 9502, v2.0 planned), `apps/booterm/` (PTY terminal, **already live at 9501 since May 2026**, Node 20 Alpine + node-pty + tmux + xterm.js, tmux session per pane, SSH-out enabled). Shared Fastify backend in `apps/server`, shared React shell in `apps/web` hosting the three surfaces as tabs. BooTerm already shares `boocode_db` — confirms cross-surface DB sharing pattern works.
|
||||
- **Single shared database, rename `boocode_db` → `boochat_db` when BooCoder lands (2026-05-22).** All three surfaces in one Postgres. Enables cross-surface joins (coder task → originating chat conversation → term debugging session).
|
||||
- **BooCode monorepo locked as 3-surface structure (2026-05-22, updated 2026-05-25).** `/opt/boocode/`: BooChat = `apps/server` + `apps/web` (:9500), BooCoder = `apps/coder` (:9502, shipped v2.0, host systemd v2.1.0), BooTerm = `apps/booterm` (:9501). All share Postgres database `boochat`.
|
||||
- **Single shared database `boochat` (shipped v2.0).** Docker service `boocode_db`. Enables cross-surface joins (coder task → chat → term session).
|
||||
- **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer (2026-05-22).** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern.
|
||||
- **External CLI agents (`opencode` / `claude` / `goose` / `pi`) live on the host, not in containers (2026-05-22).** BooCoder shells out via local-exec PTY (`node-pty`, host shell). Host install means inherit Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges.
|
||||
- **STRATEGIC PIVOT (2026-05-22): Build a Paseo-equivalent dispatcher inside BooCode. Lift patterns, not code.** Sam wants BooCode to function like Paseo without using Paseo itself. **Paseo (getpaseo/paseo) is AGPL-3.0** — incompatible with BooCode's MIT license and its network-served deployment at `code.indifferentketchup.com`. Vendoring Paseo code would force BooCode to become AGPL. Solution: **reproduce the architecture in BooCode's existing Fastify + TS + PostgreSQL + React stack, using only license-clean patterns**. Full target architecture documented in the new "Paseo-equivalent dispatcher inside BooCode" section at the top of this document. **Primary architectural template: `Dominic789654/agent-hub` (#48)** — Apache-2.0, license-clean, captures the exact three-process model (board server + dispatcher + assistant terminal) and the schema (tasks/projects/templates/pipelines/human_inbox) BooCode should reproduce. **Critical context-management primitive: Roo Code Boomerang Tasks pattern (#46)** — orchestrator with intentional capability restriction, down-pass/up-pass context discipline, no implicit inheritance. **Observation pattern: Claude Code hooks** (siropkin/budi #51 reference) — register BooCode as the hook receiver to get real-time visibility without wrapping the agent. **Phasing:** Phase 1 single-agent PTY dispatch → Phase 2 PostgreSQL queue + worker → Phase 3 Boomerang `new_task` tool → Phase 4 multi-agent + worktrees + CLI → Phase 5 pipelines + dashboard → Phase 6 handoff/loop/orchestrator skills. **This is now the dominant roadmap direction**, ahead of v1.12.x debugger fixes (queued) and v1.13/v1.14 batch work (deferred until Paseo-equivalent Phases 1–2 are scoped).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BooCode v1.x — Roadmap
|
||||
|
||||
Last updated: 2026-05-23
|
||||
Last updated: 2026-05-25
|
||||
|
||||
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
||||
|
||||
@@ -8,16 +8,16 @@ Last updated: 2026-05-23
|
||||
|
||||
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
|
||||
|
||||
- **BooChat** (`apps/chat`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. The live thing. Pick a project, chat with a local LLM, get streaming responses over WebSocket. Will rename `boocode_db` → `boochat_db` when BooCoder lands.
|
||||
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Planned, v2.0.** Both an in-process inference loop (with `pending_changes` table) AND ACP-dispatched external agents (opencode/goose) with PTY fallback (claude/pi/smallcode) — same surface, two execution paths.
|
||||
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** Node 20 Alpine + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). `/api/term/health` shares the existing `boocode_db`.
|
||||
- **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
|
||||
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0–v2.1.0.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND ACP-dispatched external agents (opencode/goose) with PTY fallback (claude/qwen).
|
||||
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
|
||||
|
||||
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (`boocode_db` → `boochat_db`).
|
||||
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
|
||||
|
||||
**Architectural commitments:**
|
||||
|
||||
- **No embeddings.** Model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, future codesight) + codecontext MCP tools. Walked away from the RAG pipeline May 2026.
|
||||
- **BooChat is read-only** through v1.x. Write tools land in BooCoder at v2.0.
|
||||
- **BooChat is read-only.** Write tools live in BooCoder (shipped v2.0).
|
||||
- **Mount strategy: blanket `/opt:rw`, permission gating at the write-tool layer.** Per-project scoping is policy, not mount. Path-guard correctness is the #1 test target for v2.0.
|
||||
- **External CLI agents (`opencode`/`claude`/`goose`/`pi`) live on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess. Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs.
|
||||
- **Protocol roles locked (2026-05-22):** **BooChat = MCP client only** (read-only tool consumer, never enables write-capable MCP servers). **BooCoder = MCP client + MCP server + ACP client (host) + ACP agent (driveable)** — full matrix. BooCoder's ACP-client role replaces raw-PTY dispatch for ACP-capable agents (opencode `opencode acp`, goose `goose acp`); PTY fallback retained for claude/pi/smallcode.
|
||||
@@ -126,6 +126,8 @@ The v1.13.x line is closed. Three batches still sit in the **In flight** column
|
||||
|
||||
**Estimated:** ~800 LoC.
|
||||
|
||||
**Shipped as `v1.14.0-outer-loop`.** Explicit `while (stepNumber < effectiveCap)` loop in `turn.ts`, per-agent `steps:` field from AGENTS.md frontmatter, `MAX_STEPS=200` ceiling, doom-loop guard migrated to loop-iteration style.
|
||||
|
||||
-----
|
||||
|
||||
## v1.14.x-mcp — single-server MCP-client proof-of-concept (NEW, 2026-05-22)
|
||||
@@ -133,7 +135,6 @@ The v1.13.x line is closed. Three batches still sit in the **In flight** column
|
||||
**Goal:** validate the MCP-client loop end-to-end against one real MCP server before committing to the full opencode `mcp/index.ts` port at v1.15. Small, throwaway-if-needed, slots between v1.14 and v1.15 without disrupting either.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. Add a hardcoded MCP client (single server) to BooChat. Initial target: **Context7** (Sam already uses it via opencode, so the config is known to work). Remote HTTP transport at `https://mcp.context7.com/mcp` with optional `CONTEXT7_API_KEY` header.
|
||||
1. Use the official `@modelcontextprotocol/sdk` TypeScript client. No SSE transport yet (deferred to v1.15). Stdio transport not needed for Context7.
|
||||
1. Tool discovery on startup: `tools/list`. Tools surface in BooChat alongside `view_file`/`grep`/etc., prefixed `context7_*` to avoid collisions.
|
||||
@@ -161,6 +162,8 @@ The v1.13.x line is closed. Three batches still sit in the **In flight** column
|
||||
|
||||
**Skip-condition:** if v1.14 finishes and Sam wants to leap straight to v1.15, fold this into the early steps of v1.15.
|
||||
|
||||
**Shipped as `v1.14.1-mcp-poc`.** Context7 MCP client validated end-to-end.
|
||||
|
||||
-----
|
||||
|
||||
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
|
||||
@@ -175,7 +178,7 @@ Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/b
|
||||
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')."
|
||||
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
|
||||
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
|
||||
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
|
||||
1. **Detection at the BooChat backend.** In `apps/server/src/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
|
||||
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
|
||||
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
|
||||
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
|
||||
@@ -217,7 +220,6 @@ Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/b
|
||||
**Goal:** wildcard permission ruleset (opencode `evaluate.ts` pattern) and a proper MCP client implementation. Foundation for BooCoder to gate writes; immediate value for codecontext to be re-wired as a real MCP server.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. Wildcard rule matcher: `{ permission, pattern, action: 'allow' | 'deny' | 'ask' }`. Last-match-wins. Per-agent rulesets layer under per-session rulesets.
|
||||
1. **Full MCP client implementation:** stdio (local subprocess) + SSE (remote HTTP) transports, `tools/list` discovery, `tools/call` invocation, OAuth via Dynamic Client Registration (RFC 7591), per-server enabled flag, **glob patterns for per-agent tool whitelisting** (matching opencode's `tools` config shape).
|
||||
1. codecontext sidecar gets re-pointed from static wrappers (v1.12) to real MCP. New connectors become a config-only addition.
|
||||
@@ -239,6 +241,8 @@ Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/b
|
||||
|
||||
**Estimated:** ~600 LoC.
|
||||
|
||||
**Shipped as `v1.15.0-mcp-multi`.** Multi-server MCP client with stdio transport + config file, per-agent tool glob patterns in AGENTS.md frontmatter.
|
||||
|
||||
-----
|
||||
|
||||
## v1.16 — codesight repo_health
|
||||
@@ -257,7 +261,9 @@ Independent batch — ships clean any time after v1.13. Low leverage unless Sam
|
||||
|
||||
## v2.0 — BooCoder: pending changes + dual execution paths + ACP host + MCP server
|
||||
|
||||
**Major version bump.** New app `apps/coder/` inside the existing monorepo (not a separate repo). Lands together with the `boocode_db` → `boochat_db` DB rename and the per-app subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder).
|
||||
**Major version bump.** New app `apps/coder/` inside the existing monorepo (not a separate repo). Shipped with database rename `boocode` → `boochat` and subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder).
|
||||
|
||||
**Shipped v2.0.0–v2.0.4.** All 8 phases complete. See retrospective below.
|
||||
|
||||
**Three protocol roles in one surface:**
|
||||
|
||||
@@ -328,6 +334,8 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
||||
|
||||
**Estimated:** ~600 LoC.
|
||||
|
||||
**Status:** Still optional. v2.0 path-guard fuzz suite (34 traversal-attack tests) passed. No production pressure to containerize yet.
|
||||
|
||||
-----
|
||||
|
||||
## v2.2 — BooCoder as ACP agent (driveable from external editors)
|
||||
@@ -350,17 +358,23 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
||||
|
||||
-----
|
||||
|
||||
## v2.1.0 — Provider picker + model discovery
|
||||
|
||||
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. `ProviderPicker` frontend component in workspace toolbar. Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
|
||||
|
||||
-----
|
||||
|
||||
## v2.x — Optional / far future
|
||||
|
||||
- **Verify gate above pending-changes** — `augmentcode/augment-swebench-agent` majority-vote ensembler pattern (K candidate diffs → ranker model picks winner). JSONL schema only, no code lift. Combine with zeroshot blind-validation invariant. v2.0+ optional batch.
|
||||
- **PR-resolver tool** — `qodo-ai/qodo-skills` PR-resolver state machine (fetch issues → batch/interactive fix → inline reply). BooCoder v2.0+.
|
||||
- **Record/replay LLM harness for tests** — `qodo-ai/qodo-cover` pattern (hashed prompt → fixture YAML). Re-implement in Vitest, don't vendor (AGPL). v1.13+ test infrastructure.
|
||||
- **HMAC-chained audit log** — `sipyourdrink-ltd/bernstein` pattern. Small lift, adds tamper-evident session history. v1.13+ optional.
|
||||
- **Tiered tool loading** — `eyaltoledano/claude-task-master` pattern (env var: `core` / `standard` / `all`). ~30 LoC in `agents.ts`. Pattern-only lift (claude-task-master is MIT + Commons Clause; reimplement). v1.13.x or v1.14.
|
||||
- **Spec directory structure** — `Fission-AI/OpenSpec` `openspec/changes/<name>/{proposal,specs,design,tasks}.md` shape for BooCode's own batch docs. Zero-dep documentation reformat, replaces ad-hoc `boocode_batchN.md` convention. v1.13.x or v1.14.
|
||||
- **Tiered tool loading** — `eyaltoledano/claude-task-master` pattern (env var: `core` / `standard` / `all`). ~30 LoC in `agents.ts`. Pattern-only lift (claude-task-master is MIT + Commons Clause; reimplement). **Shipped as `v1.13.11-tools`.**
|
||||
- **Spec directory structure** — `Fission-AI/OpenSpec` `openspec/changes/<name>/{proposal,specs,design,tasks}.md` shape for BooCode's own batch docs. Zero-dep documentation reformat, replaces ad-hoc `boocode_batchN.md` convention. **Shipped as `v1.13.10-openspec`.**
|
||||
- **`view_session_history` MCP tool** — `memovai/memov` `snap`/`mem_history`/`validate_commit` shape. Reference design for v1.13+ session-history feature.
|
||||
- **`taste-skill` anti-slop ban list** — vendor `Leonxlnx/taste-skill` SKILL.md after diff against existing `frontend-design` skill. Real value at v2.0+ when BooCoder generates frontend code (DubDrive, BooLab, Fathom).
|
||||
- **AgentLint audit pass** — manual review of BooCode's own CLAUDE.md/AGENTS.md/BOOCHAT.md/BOOCODER.md using `0xmariowu/AgentLint`'s 31 evidence-backed checks. Trim emphasis-keyword density, hit 60–120 line sweet spot, SHA-pin Actions, ensure `.env`/`CLAUDE.local.md` are gitignored. One-evening pass, immediate ROI. Optional plugin install at v1.12.x post-merge for ongoing audits.
|
||||
- **AgentLint audit pass** — manual review of BooCode's own CLAUDE.md/AGENTS.md/BOOCHAT.md/BOOCODER.md using `0xmariowu/AgentLint`'s 31 evidence-backed checks. Trim emphasis-keyword density, hit 60–120 line sweet spot, SHA-pin Actions, ensure `.env`/`CLAUDE.local.md` are gitignored. One-evening pass, immediate ROI. **Shipped as `v1.13.9-agentlint`.**
|
||||
- **`budi` install (Sam's host)** — `siropkin/budi` Claude Code 5-hook observer (`SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`). Local SQLite, sub-ms hook latency, dashboard at `localhost:7878`. Not a BooCode lift — install globally for Claude Code session observability.
|
||||
- **Multi-provider LLM** (pi-ai pattern): Only if a concrete need for Anthropic / OpenAI / Mistral direct surfaces. llama-swap covers everything today.
|
||||
- **Workflow graphs** (microsoft/agent-framework concepts): Multi-agent coordination. Conceptual reference only. Realistically a v3.x topic.
|
||||
@@ -376,9 +390,9 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
||||
|-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------|
|
||||
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|
||||
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|
||||
|`boocoder` |`100.114.205.53:9502`|`/opt:/opt:rw` (policy-gated)|Write tools + ACP host + MCP client + MCP server + external-CLI dispatch|v2.0 |
|
||||
|`boochat_db` (was `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |Live (renames at v2.0)|
|
||||
|`codecontext` |`:8765` (internal) |`/opt/projects:/workspace:ro`|MCP server for architect tools |**Live (v1.12.0)** |
|
||||
|`boocoder` |`100.114.205.53:9502`|`/opt:/opt:rw` (policy-gated)|Write tools + ACP host + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0–v2.0.4** |
|
||||
|**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|
||||
|`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
|
||||
|
||||
### Caddy routing target (post-v2.0)
|
||||
|
||||
@@ -417,11 +431,11 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
|
||||
- **v1.13.19-html-artifact-panes:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value (same v1.13.15 pattern)
|
||||
- **v1.13.20-drop-legacy-cols:** `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results` (the strangler-fig's final phase). `messages_with_parts` view rewritten to parts-only subselects via `CREATE OR REPLACE VIEW` BEFORE the drops (Postgres ordering constraint). v1.12.1 `messages_status_check`/`messages_role_check` cleanup block removed (one-shot effective long ago)
|
||||
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
||||
- **v1.14.x-mcp (NEW):** none — single-server MCP-client PoC is config-only at first, no schema change
|
||||
- **v1.14.x-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
||||
- **v1.14.x-mcp:** none — single-server MCP-client PoC is config-only at first, no schema change
|
||||
- **v1.14.x-html:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
||||
- **v1.15:** `permissions` table, `agent_permissions` join, `session_permissions` join, `mcp_servers (name, type, transport, url_or_command, enabled, config_hash, last_probed_at)` registry
|
||||
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
|
||||
- **v2.0:** `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`; `tasks`, `task_templates`, `pipelines`, `pipeline_runs`; `available_agents (name, install_path, version, supports_acp, supports_mcp_client, last_probed_at)`; `human_inbox` view; DB rename `boocode_db` → `boochat_db`
|
||||
- **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode` → `boochat`
|
||||
- **v2.2:** none (`boocoder acp` is a new entry point, not a schema change)
|
||||
|
||||
-----
|
||||
@@ -441,17 +455,17 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
|
||||
|`anomalyco/opencode` |MIT, TS |`experimental_repairToolCall` via AI SDK v6 |v1.13.3 ✅ |
|
||||
|`anomalyco/opencode` |MIT, TS |Two-tier compaction prune (`message_parts.hidden_at` + tier logic) |v1.13.4 ✅ |
|
||||
|`anomalyco/opencode` |MIT, TS |`tool/truncate.ts` truncation + outputPath pattern (adapted: opaque id) |v1.13.5 ✅ |
|
||||
|`anomalyco/opencode` |MIT, TS |0.85×ctx_max overflow trigger formula |v1.13.9 (planned) |
|
||||
|`anomalyco/opencode` |MIT, TS |`session/prompt.ts` `runLoop()` outer agent loop + `agent.steps` cap |v1.14 |
|
||||
|**Anthropic MCP SDK (TypeScript)** |**MIT** |**MCP client, single-server PoC** |**v1.14.x-mcp** |
|
||||
|`anomalyco/opencode` |MIT, TS |0.85×ctx_max overflow trigger formula |v1.13.7-compaction-trigger ✅ |
|
||||
|`anomalyco/opencode` |MIT, TS |`session/prompt.ts` `runLoop()` outer agent loop + `agent.steps` cap |v1.14.0-outer-loop ✅ |
|
||||
|**Anthropic MCP SDK (TypeScript)** |**MIT** |**MCP client, single-server PoC** |**v1.14.1-mcp-poc ✅** |
|
||||
|**`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`** |**(blog, pattern only)** |**HTML-output bias rule + use-case taxonomy** |**v1.14.x-html** |
|
||||
|**`anthropics/skills/web-artifacts-builder`** |**MIT (design-principle reference)** |**"Avoid AI slop" conventions inline in AGENTS.md** |**v1.14.x-html** |
|
||||
|**`mgechev/skills-best-practices`** |**MIT (pattern)** |**4-step skill validation protocol with paste-ready prompts** |**v1.13.12 (skills audit)** |
|
||||
|**`mgechev/skillgrade`** |**MIT** |**Agent-agnostic skill eval framework (eval.yaml + smoke/reliable/regression presets)** |**v1.13.12 (skills audit) + ongoing** |
|
||||
|**`blog.codeminer42.com/stop-putting-best-practices-in-skills/`** |**(blog, pattern only)** |**Rules→recipes split: skills 6% invoke vs AGENTS.md 100% present** |**v1.13.12 (skills audit)** |
|
||||
|**`platform.claude.com/docs/.../agent-skills/best-practices`** |**(docs, canonical)** |**500-line ceiling, gerund naming, progressive-disclosure patterns, MCP `ServerName:tool_name` format** |**v1.13.12 + all future skills** |
|
||||
|`anomalyco/opencode` |MIT, TS |`permission/evaluate.ts` wildcard ruleset |v1.15 |
|
||||
|`anomalyco/opencode` |MIT, TS |`mcp/index.ts` MCP client (stdio + SSE, tools/list, tools/call, OAuth RFC 7591) |v1.15 |
|
||||
|`anomalyco/opencode` |MIT, TS |`permission/evaluate.ts` wildcard ruleset |v1.15.0-mcp-multi (planned, not shipped) |
|
||||
|`anomalyco/opencode` |MIT, TS |`mcp/index.ts` MCP client (stdio + SSE, tools/list, tools/call, OAuth RFC 7591) |v1.15.0-mcp-multi ✅ |
|
||||
|`Aider-AI/aider` |Apache-2.0 |Fallback `aider/queries/tree-sitter-*.scm` grammars |v1.12 (fallback) |
|
||||
|`cline/cline` |Apache-2.0 |Plan/Act invariant (absorbed into v1.15 permissions) |v1.15 |
|
||||
|`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 |
|
||||
@@ -503,8 +517,8 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
|
||||
|
||||
### Monorepo / multi-app structure (2026-05-22, locked)
|
||||
|
||||
- **BooCode is a 3-app monorepo** at `/opt/boocode/`: `apps/chat` (read-only, currently the live thing at 9500), `apps/coder` (write tools + external CLI dispatch, 9502, v2.0 planned), `apps/booterm` (PTY terminal, **live since May 2026 at 9501**). Shared `apps/server` (Fastify backend) and `apps/web` (React shell hosting the three surfaces as tabs).
|
||||
- **Single shared database, rename `boocode_db` → `boochat_db` when BooCoder lands.** All three surfaces in one Postgres. Cross-surface joins are valuable (coder task → originating chat → term debugging session). Separate databases would break this.
|
||||
- **BooCode is a 3-surface monorepo** at `/opt/boocode/`: BooChat (`apps/server` + `apps/web`, :9500), BooCoder (`apps/coder`, :9502, **shipped v2.0–v2.1.0**, host systemd), BooTerm (`apps/booterm`, :9501, live since May 2026). One React SPA hosts chat, coder, and terminal panes.
|
||||
- **Single shared database `boochat`.** Docker service `boocode_db`, all three surfaces connect to the same Postgres. Cross-surface joins are valuable (coder task → originating chat → term debugging session).
|
||||
- **Mount strategy: blanket `/opt:rw`, policy enforcement at the write-tool layer.** Per-project scoping is logic, not mount. Path-guard correctness becomes the highest-priority test target for v2.0 — fuzz it, property-test it, every traversal-attack pattern (including MCP-served filesystem writes).
|
||||
- **External CLI agents on the host, not in containers.** BooCoder shells out via local-exec PTY or ACP subprocess (`node-pty`, host shell, or `child_process.spawn('opencode', ['acp'])`). Host install inherits Sam's existing `~/.opencode/`, `~/.claude/`, `~/.config/goose/` configs without re-mounting. Containerize later only if a concrete reason emerges.
|
||||
|
||||
@@ -527,6 +541,14 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
|
||||
|
||||
The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMAJOR.MINOR.PATCH-slug` form: **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → v1.13.17-cross-repo-reads ✅ → v1.13.18-codecontext-file-path ✅ → v1.13.19-html-artifact-panes ✅ → v1.13.20-drop-legacy-cols ✅** → umbrella `v1.13` ✅. **Do not fold** was the discipline — each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches. Held throughout; CHANGELOG.md is the per-tag canonical record.
|
||||
|
||||
### v1.14–v2.1 shipped (2026-05-25)
|
||||
|
||||
- **v1.14.0-outer-loop** ✅ — explicit `while` loop, per-agent `steps:` cap, doom-loop migration
|
||||
- **v1.14.1-mcp-poc** ✅ — Context7 MCP client validated
|
||||
- **v1.15.0-mcp-multi** ✅ — multi-server MCP client, stdio transport, per-agent tool globs
|
||||
- **v2.0.0-alpha through v2.0.4-hardening** ✅ — full BooCoder line: write tools, dispatcher (ACP/PTY), MCP server (6 tools, stdio, 10-question eval passed), CLI client, human inbox, Boomerang `new_task` orchestration, path-guard fuzz suite (34 traversal-attack tests)
|
||||
- **v2.1.0-provider-picker** ✅ — 5-provider registry, model discovery, `/api/providers` route, `ProviderPicker` UI, agent-probe startup probe
|
||||
|
||||
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
|
||||
|
||||
The v1.13.x line ran 21 batches; planned-vs-shipped numbering diverged for half of them, and three batches had material scope revisions mid-design. Pattern that emerged and is worth carrying forward:
|
||||
@@ -548,7 +570,7 @@ The v1.13.x line ran 21 batches; planned-vs-shipped numbering diverged for half
|
||||
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
|
||||
- **v1.13.6** — compaction head-assembly audit + reasoning fix. Closed the Q3 reasoning gap from v1.13.1-C. Tagged on `81d837c`.
|
||||
- **v1.13.7** — stability bundle: includeUsage fix + trim guards + payload filter + budget bump. Surfaces tokens (closes a v1.13.1-A latent regression where `result.usage` resolved empty), kills the empty-bubble + ActionRow noise between tool calls on single-tool-call turns, and unblocks Continue after cap-hit on chats that have trailing empty/failed assistants.
|
||||
- **v1.13.6 (numbering re-aligned)** — system-prompt prefix verify-and-measure batch (originally numbered v1.13.8 in the planning doc). Reframed mid-design from "add a `system_prompt_cache` table" to "instrument-and-prove" after recon showed input-layer mtime caches already achieve byte-stable prefixes. Smoke confirmed zero drift across 5 turns; dropped the planned DB table.
|
||||
- **v1.13.6-prefix-stability** — system-prompt prefix verify-and-measure batch (originally numbered v1.13.8 in the planning doc). Reframed mid-design from "add a `system_prompt_cache` table" to "instrument-and-prove" after recon showed input-layer mtime caches already achieve byte-stable prefixes. Smoke confirmed zero drift across 5 turns; dropped the planned DB table. Tagged on `81d837c`.
|
||||
- **v1.13.7-compaction-trigger** — 0.85×ctx_max early trigger (planned as v1.13.8 / v1.13.9).
|
||||
- **v1.13.8-tool-cost** — `tool_cost_stats` SQL view + AgentPicker tooltip surfacing (planned as v1.13.9 / v1.13.10).
|
||||
- **v1.13.9-agentlint** — instruction-file AgentLint pass (planned as part of v1.13.11 skills audit; split into its own batch when it grew larger than fitting).
|
||||
|
||||
@@ -12,10 +12,10 @@ Audit guidance files in a BooCode project against a 10-dimension rubric, then pr
|
||||
Find every guidance file in the project. The expected set:
|
||||
|
||||
- `CLAUDE.md` (repo root) — engineering conventions, gotchas, commands
|
||||
- `AGENTS.md` (repo root) — **agent navigation** (doc map, task routing, openspec usage). Not the agent registry.
|
||||
- `BOOCHAT.md` (repo root) — container guidance for the read-only chat surface
|
||||
- `BOOCODER.md` (repo root) — container guidance for the future write-capable surface (currently a stub)
|
||||
- `data/AGENTS.md` — single-file tier-2 agent registry, `## H2` per agent
|
||||
- `AGENTS.md` (repo root) — non-BooCode convention; rare in this repo
|
||||
- `BOOCODER.md` (repo root) — container guidance for the write-capable surface
|
||||
- `data/AGENTS.md` — single-file tier-2 **agent registry**, `## H2` per agent
|
||||
|
||||
Glob with `find_files` then load each with `view_file`:
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
CODECONTEXT_URL: http://codecontext:8080
|
||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
BOOCODER_URL: http://100.114.205.53:9502
|
||||
volumes:
|
||||
- /opt:/opt
|
||||
- /opt/projects:/opt/projects:rw
|
||||
@@ -50,27 +51,29 @@ services:
|
||||
networks:
|
||||
- boocode_net
|
||||
|
||||
boocoder:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/coder/Dockerfile
|
||||
container_name: boocoder
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "100.114.205.53:9502:3000"
|
||||
env_file: .env
|
||||
environment:
|
||||
CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
volumes:
|
||||
- /opt:/opt:rw
|
||||
- /opt/projects:/opt/projects:rw
|
||||
- ./data:/data
|
||||
- /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
|
||||
depends_on:
|
||||
- boocode_db
|
||||
networks:
|
||||
- boocode_net
|
||||
# v2.1.1: boocoder moved to systemd service on host (boocoder.service).
|
||||
# Kept commented for rollback reference.
|
||||
# boocoder:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: apps/coder/Dockerfile
|
||||
# container_name: boocoder
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "100.114.205.53:9502:3000"
|
||||
# env_file: .env
|
||||
# environment:
|
||||
# CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
|
||||
# DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
# volumes:
|
||||
# - /opt:/opt:rw
|
||||
# - /opt/projects:/opt/projects:rw
|
||||
# - ./data:/data
|
||||
# - /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
|
||||
# depends_on:
|
||||
# - boocode_db
|
||||
# networks:
|
||||
# - boocode_net
|
||||
|
||||
boocode_db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
122
docs/ARCHITECTURE.md
Normal file
122
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# BooCode architecture
|
||||
|
||||
Last updated: 2026-05-25. **Navigation:** `AGENTS.md`. **Deep reference:** `CLAUDE.md`.
|
||||
|
||||
## System overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph client [Browser]
|
||||
SPA["apps/web React SPA"]
|
||||
end
|
||||
|
||||
subgraph edge [Edge]
|
||||
Caddy --> Authelia
|
||||
end
|
||||
|
||||
subgraph host ["Host 100.114.205.53"]
|
||||
subgraph docker [Docker boocode_net]
|
||||
BooChat["boocode container<br/>apps/server + built web<br/>:9500"]
|
||||
BooTerm["booterm container<br/>apps/booterm<br/>:9501"]
|
||||
PG[("boocode_db<br/>Postgres 16<br/>database: boochat<br/>host :5500")]
|
||||
CC["codecontext sidecar<br/>:8080 internal"]
|
||||
end
|
||||
BooCoder["boocoder.service<br/>apps/coder<br/>:9502"]
|
||||
Agents["Host CLI agents<br/>opencode goose claude qwen"]
|
||||
LLM["llama-swap<br/>100.101.41.16:8401"]
|
||||
end
|
||||
|
||||
Authelia --> SPA
|
||||
SPA -->|"HTTP /api WS /api/ws"| BooChat
|
||||
SPA -->|"WS /ws/term"| BooTerm
|
||||
SPA -->|"HTTP /api/coder proxy<br/>WS direct"| BooCoder
|
||||
|
||||
BooChat --> PG
|
||||
BooTerm --> PG
|
||||
BooCoder --> PG
|
||||
BooChat -->|"HTTP tools"| CC
|
||||
BooChat -->|"streamText"| LLM
|
||||
BooCoder -->|"native inference"| LLM
|
||||
BooCoder -->|"ACP or PTY spawn"| Agents
|
||||
Agents --> LLM
|
||||
```
|
||||
|
||||
## Three surfaces, one database
|
||||
|
||||
| Surface | Code | Runtime | Primary role |
|
||||
|---------|------|---------|--------------|
|
||||
| BooChat | `apps/server` + `apps/web` | Docker | Read-only chat, file tools, MCP client, skills |
|
||||
| BooTerm | `apps/booterm` + terminal panes in `apps/web` | Docker | tmux + xterm.js PTY panes |
|
||||
| BooCoder | `apps/coder` + `CoderPane` in `apps/web` | Host systemd | Write tools, task queue, ACP/PTY agent dispatch |
|
||||
|
||||
All surfaces share Postgres (`boochat` DB). Cross-surface joins link chats, tasks, and sessions.
|
||||
|
||||
## BooChat request path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant W as apps/web
|
||||
participant S as apps/server
|
||||
participant DB as Postgres
|
||||
participant L as llama-swap
|
||||
|
||||
U->>W: POST message
|
||||
W->>S: /api/sessions/:id/messages
|
||||
S->>DB: user + assistant streaming rows
|
||||
S->>S: inference.enqueue()
|
||||
loop outer step loop
|
||||
S->>L: streamText
|
||||
L-->>S: deltas / tool calls
|
||||
S->>W: WS frames via broker
|
||||
opt tool calls
|
||||
S->>S: executeToolPhase
|
||||
S->>DB: message_parts
|
||||
end
|
||||
end
|
||||
S->>DB: finalize message
|
||||
S->>W: session_updated user frame
|
||||
```
|
||||
|
||||
Key modules: `services/inference/turn.ts` (outer loop), `stream-phase.ts` (AI SDK adapter), `tool-phase.ts`, `services/broker.ts`, `hooks/useSessionStream.ts`.
|
||||
|
||||
## BooCoder execution paths
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Msg["User message<br/>CoderPane"] --> Route{"provider?"}
|
||||
Route -->|boocode| Inf["In-process inference<br/>pending_changes queue"]
|
||||
Route -->|external| Task["tasks row<br/>dispatcher poll"]
|
||||
Task --> ACP["ACP dispatch<br/>opencode goose"]
|
||||
Task --> PTY["PTY dispatch<br/>claude qwen"]
|
||||
ACP --> Host["spawn install_path<br/>on host"]
|
||||
PTY --> Host
|
||||
Inf --> Apply["apply_pending → disk"]
|
||||
```
|
||||
|
||||
Since v2.1.0, BooCoder runs on the host (not Docker). Agent binaries spawn directly — no SSH tunnel.
|
||||
|
||||
## Supporting services
|
||||
|
||||
| Service | Reachability | Purpose |
|
||||
|---------|--------------|---------|
|
||||
| codecontext | `http://codecontext:8080` from Docker network | Code graph / symbol analysis (Go sidecar) |
|
||||
| llama-swap | `LLAMA_SWAP_URL` env | Local LLM inference + model props |
|
||||
| SearXNG | `SEARXNG_URL` (Tailscale Fathom) | `web_search` / `web_fetch` when enabled |
|
||||
| MCP servers | `/data/mcp.json` config | Optional tools (e.g. Context7), read-only in BooChat |
|
||||
|
||||
## Config and data files
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `data/AGENTS.md` | Global agent registry (bind-mounted `/data/AGENTS.md`) |
|
||||
| `data/mcp.json` | MCP server config (opencode-compatible shape) |
|
||||
| `data/skills/` | On-demand skill library |
|
||||
| `BOOCHAT.md` / `BOOCODER.md` | Container guidance (mtime-cached into system prompt) |
|
||||
| `apps/server/src/schema.sql` | Canonical DB schema |
|
||||
|
||||
## Deploy topology
|
||||
|
||||
- **BooChat + BooTerm + Postgres + codecontext:** `docker compose up --build -d` from `/opt/boocode`
|
||||
- **BooCoder:** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Ports bind to Tailscale IP** `100.114.205.53`, not `0.0.0.0` — use that IP for host smoke curls
|
||||
312
docs/DEFERRED-WORK.md
Normal file
312
docs/DEFERRED-WORK.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Deferred work — post stale cleanup (2026-05-26)
|
||||
|
||||
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
|
||||
|
||||
Last updated: 2026-05-26
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Item | Category | User impact | Effort | Risk if left alone |
|
||||
|------|----------|-------------|--------|-------------------|
|
||||
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
|
||||
| ~~Skip ACP cold probe when DB fresh~~ | ~~Performance~~ | ~~Medium~~ | ~~Medium~~ | **RESOLVED — v2.3 provider lifecycle** |
|
||||
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
||||
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
||||
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
|
||||
|
||||
---
|
||||
|
||||
## 1. Task cancel → abort external ACP/PTY child
|
||||
|
||||
### Current behavior
|
||||
|
||||
External agent tasks (opencode, cursor, claude, qwen, goose, etc.) flow through `apps/coder/src/services/dispatcher.ts` path B:
|
||||
|
||||
1. Create git worktree (`worktrees.ts` → local `hostExec`)
|
||||
2. Spawn ACP or PTY child with an **`AbortController`** (`ac`) passed as `signal`
|
||||
3. Stream assistant output over session WS
|
||||
4. Diff worktree → `pending_changes`
|
||||
5. Cleanup worktree
|
||||
|
||||
The abort signal is **created per task** but **never registered anywhere cancel can reach**:
|
||||
|
||||
```typescript
|
||||
// dispatcher.ts — ac is local to dispatchExternal(); no Map<taskId, AbortController>
|
||||
const ac = new AbortController();
|
||||
// ...
|
||||
await dispatchViaAcp({ ..., signal: ac.signal });
|
||||
// or
|
||||
await dispatchViaPty({ ..., signal: ac.signal });
|
||||
```
|
||||
|
||||
Cancel paths today:
|
||||
|
||||
| Route | What it does | Gap |
|
||||
|-------|--------------|-----|
|
||||
| `POST /api/tasks/:id/cancel` | Sets DB `state = 'cancelled'`, calls `cancelPendingPermission`, tries `inference.cancel()` on session chats | **`inference.cancel` only affects native boocode streaming** — not ACP/PTY children |
|
||||
| `POST /api/sessions/:sessionId/stop` | Same — loops open chats, calls `inference.cancel` | Same gap for external tasks tied to that session |
|
||||
| Dispatcher `stop()` on shutdown | Sets `stopping = true`; in-flight external task may finish or mark cancelled at end | No immediate child kill |
|
||||
|
||||
`dispatchViaAcp` and `dispatchViaPty` **do** honor `signal` when aborted (worktree ops, stream read, spawn teardown). The wiring from HTTP cancel → `ac.abort()` is missing.
|
||||
|
||||
There is also **no frontend** calling task cancel today (`grep` across `apps/web` finds no `cancelTask` usage). Stop in CoderPane targets native inference only.
|
||||
|
||||
### Symptoms users would see
|
||||
|
||||
- Click Stop / cancel task → DB says `cancelled` but agent process keeps running on host
|
||||
- Assistant row may stay `streaming` until the child exits naturally
|
||||
- Worktree under `/tmp/booworktrees/<taskId>` may linger until dispatcher cleanup or manual removal
|
||||
- Permission prompts cancel correctly (`cancelPendingPermission`) — that part works
|
||||
|
||||
### Proposed implementation
|
||||
|
||||
**Phase A — backend registry (minimum viable)**
|
||||
|
||||
1. Add `Map<taskId, AbortController>` (or `{ ac, childPid? }`) in dispatcher module scope
|
||||
2. On `dispatchExternal` start: `activeAbort.set(taskId, ac)`
|
||||
3. On task completion/failure/cleanup: `activeAbort.delete(taskId)`
|
||||
4. Export `cancelExternalTask(taskId: string): boolean` from dispatcher
|
||||
5. In `POST /api/tasks/:id/cancel`:
|
||||
- Call `cancelExternalTask(taskId)` **before** or **instead of** `inference.cancel` when task is external (`execution_path` or agent !== boocode)
|
||||
- Mark assistant message `cancelled` / `failed` and publish `message_complete` + `chat_status: idle` on session WS
|
||||
6. Wire `POST /api/sessions/:sessionId/stop` to find **running external tasks** for that session and abort them too
|
||||
|
||||
**Phase B — child process kill**
|
||||
|
||||
- PTY: store `child` ref from `spawn`; on abort, `child.kill('SIGTERM')` then SIGKILL after timeout
|
||||
- ACP: `acp-dispatch` already uses signal; ensure `createAcpNdJsonStream` cancel kills the underlying spawn (verify in `acp-stream.ts`)
|
||||
- Worktree: on abort mid-flight, call `cleanupWorktree` in `finally` block
|
||||
|
||||
**Phase C — frontend**
|
||||
|
||||
- CoderPane Stop button: if `provider !== 'boocode'` and a `task_id` is active, call `POST /api/coder/tasks/:id/cancel` (needs API client method)
|
||||
- Show cancelled state in UI (task row or composer status)
|
||||
|
||||
### Files likely touched
|
||||
|
||||
- `apps/coder/src/services/dispatcher.ts` — registry, export cancel
|
||||
- `apps/coder/src/routes/tasks.ts` — call dispatcher cancel
|
||||
- `apps/coder/src/routes/messages.ts` — session stop for external tasks
|
||||
- `apps/coder/src/services/acp-dispatch.ts`, `pty-dispatch.ts` — verify signal → kill
|
||||
- `apps/web/src/api/client.ts`, `CoderPane.tsx` — Stop wiring
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Cancel a running opencode/cursor task → child process gone within 5s (`ps` / `pgrep` on host)
|
||||
- Task row `state = 'cancelled'`, assistant message not left `streaming`
|
||||
- Worktree directory removed (best-effort)
|
||||
- Native boocode cancel unchanged
|
||||
- No regression on permission-denied / blocked flows
|
||||
|
||||
### Open questions
|
||||
|
||||
- Should cancel be **best-effort** (mark cancelled even if kill fails) or **fail closed** until process dies?
|
||||
- Arena parallel tasks: cancel one contestant vs whole arena?
|
||||
- Blocked task waiting on permission: cancel already resolves waiter — confirm ACP session teardown order
|
||||
|
||||
---
|
||||
|
||||
## 2. ~~Skip ACP cold probe when DB models are fresh~~ **RESOLVED — v2.3 provider lifecycle**
|
||||
|
||||
Addressed in [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/design.md). The v2.3 snapshot module (`provider-snapshot.ts`) uses DB `available_agents` models as the warm path and only cold-probes on explicit `POST /api/providers/refresh`. Opening the provider picker no longer triggers any probe. `PROVIDER_PROBE_TTL_MS` env var (default 24h) controls stale-model self-heal.
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified `packages/types` for provider snapshot JSON
|
||||
|
||||
### Current behavior
|
||||
|
||||
Provider snapshot shapes are **duplicated** (not byte-identical exports):
|
||||
|
||||
| Location | Types |
|
||||
|----------|-------|
|
||||
| `apps/coder/src/services/provider-types.ts` | `ProviderSnapshotEntry`, `AgentCommand`, `ProviderModel`, … |
|
||||
| `apps/web/src/api/types.ts` | Same names, hand-maintained |
|
||||
| WS frames | `agent_commands` frame in `ws-frames.ts` (server + web copy) |
|
||||
|
||||
Today drift is caught by:
|
||||
|
||||
- TypeScript at compile time when fields are added to one side only
|
||||
- `provider-snapshot.test.ts` on coder
|
||||
- Manual review during v2.2 batch
|
||||
|
||||
There is **no** `packages/` workspace yet (monorepo is `apps/server`, `apps/web`, `apps/coder`, `apps/booterm` only).
|
||||
|
||||
### Options
|
||||
|
||||
**A. Zod schema + inferred types (lighter)**
|
||||
|
||||
- Add `ProviderSnapshotEntrySchema` in server or coder
|
||||
- Web imports schema for runtime validation on fetch (optional)
|
||||
- Parity test: `expect(webTypeKeys).toEqual(zodShapeKeys)` — similar to existing `ws-frames.test.ts`
|
||||
|
||||
**B. Shared `packages/types` package**
|
||||
|
||||
```
|
||||
packages/types/
|
||||
package.json # @boocode/types
|
||||
src/provider.ts # interfaces + zod
|
||||
src/ws-frames.ts # move duplicated unions here
|
||||
```
|
||||
|
||||
- `apps/coder`, `apps/web`, `apps/server` depend on `workspace:*`
|
||||
- Requires NodeNext export map, build order (`tsc` for types package first)
|
||||
- Biggest payoff if WS frames + provider + task types all move
|
||||
|
||||
**C. Status quo + discipline**
|
||||
|
||||
- Keep duplicated TS interfaces
|
||||
- Add one test file importing both sides and asserting structural equality
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
| Approach | Setup cost | Runtime safety | Best when |
|
||||
|----------|------------|----------------|-----------|
|
||||
| A — Zod in coder | Low | Validate API responses | Snapshot is coder-only API |
|
||||
| B — packages/types | High | Full shared source | Many cross-app types growing |
|
||||
| C — parity test | Lowest | Compile-time only | Rare schema changes |
|
||||
|
||||
### Recommendation
|
||||
|
||||
Start with **A or C** unless planning a broader “shared types” initiative (tasks, permissions, arena). Full `packages/types` is justified when a third consumer appears or WS frame duplication becomes painful again.
|
||||
|
||||
### Files likely touched (option B)
|
||||
|
||||
- New `packages/types/`
|
||||
- Root `pnpm-workspace.yaml`
|
||||
- `apps/web/tsconfig`, `apps/coder/tsconfig` — path references
|
||||
- Strip duplicate blocks from `apps/web/src/api/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4. Large file splits (refactor when touched)
|
||||
|
||||
Not user-facing defects — deferred to avoid scope creep in the stale batch. Split when the next feature touches the file heavily.
|
||||
|
||||
### 4.1 `CoderPane.tsx` (~663 lines)
|
||||
|
||||
**Responsibilities today (single component):**
|
||||
|
||||
- Session WS + polling fallback for messages
|
||||
- Pending changes fetch/apply/discard
|
||||
- External vs native send paths (`AgentComposerBar`, slash commands, skill invoke)
|
||||
- Permission card + agent commands hint
|
||||
- Provider snapshot–driven composer state
|
||||
|
||||
**Suggested extractions:**
|
||||
|
||||
| Hook / module | Owns |
|
||||
|---------------|------|
|
||||
| `useCoderMessages(sessionId)` | WS subscribe, poll-when-disconnected, temp-id dedup |
|
||||
| `usePendingChanges(sessionId)` | List, apply, discard, diff preview trigger |
|
||||
| `CoderComposerFooter` | AgentComposerBar + hints + permission card layout |
|
||||
|
||||
**Trigger to do it:** next CoderPane feature (e.g. task cancel UI, multi-task inbox) or when file exceeds ~800 lines.
|
||||
|
||||
### 4.2 `ChatInput.tsx` (~669 lines)
|
||||
|
||||
**Responsibilities:** attachments, drag/drop, paste-as-file, `@` mentions, slash picker, agent picker row, context bar, send/submit, registry for send-to-chat.
|
||||
|
||||
**Suggested extractions:**
|
||||
|
||||
| Hook / module | Owns |
|
||||
|---------------|------|
|
||||
| `useSlashCommandInput(...)` | `slashState`, `isSlashCommandToken`, picker open/close |
|
||||
| `useFileMention(...)` | `@` detection, file index fetch, popover state |
|
||||
| `useAttachments(...)` | chips, drop, paste, MAX_ATTACHMENTS |
|
||||
|
||||
Slash helpers already live in `lib/slash-command.ts`; hooks would consume them.
|
||||
|
||||
### 4.3 `dispatcher.ts` (~483 lines)
|
||||
|
||||
**Two paths in one file:**
|
||||
|
||||
- Path A: native boocode — enqueue inference, wait for message completion
|
||||
- Path B: external — worktree, ACP/PTY, diff, pending_changes
|
||||
|
||||
**Suggested split:**
|
||||
|
||||
- `dispatcher-native.ts` — poll loop pick-up for `provider = boocode` or no agent
|
||||
- `dispatcher-external.ts` — path B + abort registry (pairs with item §1)
|
||||
- `dispatcher.ts` — thin poll loop + routing
|
||||
|
||||
### 4.4 `AgentComposerBar.tsx` (~323 lines)
|
||||
|
||||
Optional split: **provider/model/mode/thinking pickers** vs **persistence** (`AgentSessionConfig` localStorage/session). Lower priority — file is manageable.
|
||||
|
||||
### 4.5 `acp-dispatch.ts` (~294 lines)
|
||||
|
||||
Stream setup already extracted to `acp-stream.ts`. Remaining split candidate: **session lifecycle** (create → prompt loop → teardown) vs **permission/tool side effects**.
|
||||
|
||||
### General rule
|
||||
|
||||
Split only when:
|
||||
|
||||
1. A new feature needs a clear seam, or
|
||||
2. Review feedback repeatedly hits the same file, or
|
||||
3. Tests need isolated units (e.g. dispatcher external path)
|
||||
|
||||
Avoid drive-by splits — they churn blame without shipping user value.
|
||||
|
||||
---
|
||||
|
||||
## 5. Retire `apps/coder/web/` fallback SPA
|
||||
|
||||
### What it is
|
||||
|
||||
Standalone Vite React app (`@boocode/coder-web`) built into `apps/coder/web/dist/` and served by BooCoder Fastify at `:9502` when no BooChat proxy is in front.
|
||||
|
||||
**Primary UI:** `CoderPane` inside BooChat SPA (`apps/web`) — API via `/api/coder/*` proxy, WS to `:9502`.
|
||||
|
||||
**Fallback UI:** direct visit to `http://100.114.205.53:9502` — own `api/client.ts`, `ChatPane`, `DiffPane`, duplicated types.
|
||||
|
||||
### Why it was kept
|
||||
|
||||
- Host-only debugging without full BooChat stack
|
||||
- Historical path before CoderPane integration
|
||||
- Zero dependency on Authelia-protected BooChat origin
|
||||
|
||||
### Cost of keeping
|
||||
|
||||
- Separate build step in coder deploy
|
||||
- Duplicate message/stream types and API paths
|
||||
- Features land in CoderPane first; fallback rots unless manually updated
|
||||
|
||||
### Options
|
||||
|
||||
| Option | When to choose |
|
||||
|--------|----------------|
|
||||
| **Keep** | Still use `:9502` directly for debugging or demos |
|
||||
| **Freeze** | Stop feature work; build only on release for emergency access |
|
||||
| **Remove** | Always use BooChat; `:9502` serves health + WS + API only (or minimal static “open in BooChat” page) |
|
||||
|
||||
### Removal checklist (if chosen)
|
||||
|
||||
1. Confirm Sam never uses standalone UI (bookmarks, systemd docs, BOOCODER.md)
|
||||
2. Remove `apps/coder/web/` package and static serve from `apps/coder/src/index.ts`
|
||||
3. Update Dockerfile/coder build scripts
|
||||
4. Keep WS + REST routes — CoderPane depends on them
|
||||
5. Optional: single-page static “Use code.indifferentketchup.com” at `/`
|
||||
|
||||
---
|
||||
|
||||
## Suggested batch ordering
|
||||
|
||||
If picking these up as openspec batches:
|
||||
|
||||
1. **Task cancel abort** — highest correctness gap; unblocks honest Stop button in CoderPane
|
||||
2. **ACP probe skip** — quick win for provider picker latency once semantics agreed
|
||||
3. **CoderPane hook extraction** — natural follow-on when adding cancel UI
|
||||
4. **Zod parity or packages/types** — when next WS/provider field is added
|
||||
5. **Retire coder/web** — only after explicit “I don’t use :9502 UI” confirmation
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) — resolved stale items
|
||||
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — BooChat / BooCoder surfaces
|
||||
- [`openspec/changes/v2-2-paseo-providers/design.md`](../openspec/changes/v2-2-paseo-providers/design.md) — provider snapshot API
|
||||
- [`BOOCODER.md`](../BOOCODER.md) — dispatch, worktrees, pending changes
|
||||
77
docs/STALE-DEPRECATED.md
Normal file
77
docs/STALE-DEPRECATED.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Stale / deprecated inventory
|
||||
|
||||
Review list for Sam — **do not delete blindly**. Items marked "candidate remove" have no known runtime imports; items marked "deprecated" are still referenced or documented.
|
||||
|
||||
Last updated: 2026-05-26 (stale cleanup batch)
|
||||
|
||||
---
|
||||
|
||||
## Resolved in stale cleanup (2026-05-26)
|
||||
|
||||
| Item | Resolution |
|
||||
|------|------------|
|
||||
| `ProviderPicker.tsx` | **Removed** — replaced by `AgentComposerBar` |
|
||||
| `SkillSlashCommand.tsx` | **Removed** — inlined `SlashCommandPicker` in `ChatInput` |
|
||||
| `Provider` type + `api.coder.providers()` | **Removed** |
|
||||
| `GET /api/providers` (flat list) | **Removed** — snapshot is canonical |
|
||||
| `ssh.ts` + worktree SSH | **Removed** — `worktrees.ts` uses local `host-exec.ts` |
|
||||
| `ProviderCommand` vs `AgentCommand` | **Consolidated** — `AgentCommand` in `provider-types.ts` |
|
||||
| `pty-dispatch` `AgentCommand` | **Renamed** — `PtySpawnSpec` / `buildPtySpawnSpec` |
|
||||
| `ProviderSnapshotStatus: 'unavailable'` | **Dropped** — only `'ready'` \| `'error'` |
|
||||
| Skill invoke duplication | **Extracted** — `@boocode/server/skill-invoke` |
|
||||
| `resolveChatId` duplication | **Extracted** — `apps/coder/src/routes/chat-resolve.ts` |
|
||||
| Qwen settings parse | **Shared** — `qwen-settings.ts` with `readFile` |
|
||||
| ChatInput slash regex | **Shared** — `lib/slash-command.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Frontend (apps/web)
|
||||
|
||||
_No open stale items from the 2026-05-26 review._
|
||||
|
||||
---
|
||||
|
||||
## BooCoder backend (apps/coder)
|
||||
|
||||
| Item | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| [`apps/coder/web/`](../apps/coder/web/) | **Fallback SPA** | Standalone BooCoder UI at `:9502`. Primary UI is `CoderPane` in BooChat SPA. Keep unless host-only access is dropped. |
|
||||
|
||||
---
|
||||
|
||||
## Large files (refactor when touched, not delete)
|
||||
|
||||
| File | ~Lines | Split suggestion |
|
||||
|------|--------|------------------|
|
||||
| [`CoderPane.tsx`](../apps/web/src/components/panes/CoderPane.tsx) | 660+ | Extract `useCoderMessages`, pending changes hook, composer footer |
|
||||
| [`ChatInput.tsx`](../apps/web/src/components/ChatInput.tsx) | 670+ | Extract slash/mention hooks |
|
||||
| [`dispatcher.ts`](../apps/coder/src/services/dispatcher.ts) | 480+ | Split native vs external paths |
|
||||
| [`AgentComposerBar.tsx`](../apps/web/src/components/AgentComposerBar.tsx) | 320+ | Optional: prefs vs picker UI |
|
||||
| [`acp-dispatch.ts`](../apps/coder/src/services/acp-dispatch.ts) | 300+ | Stream setup now in `acp-stream.ts`; session lifecycle could split further |
|
||||
|
||||
---
|
||||
|
||||
## Docs / plans superseded by v2.2
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| [`docs/superpowers/plans/2026-05-25-provider-picker-backend.md`](../docs/superpowers/plans/2026-05-25-provider-picker-backend.md) | Pre-v2.2 flat `/api/providers` plan; header marks superseded. |
|
||||
| Roadmap / CHANGELOG mentions of `ProviderPicker` | Historical truth for v2.1.0 — leave as release record. |
|
||||
|
||||
---
|
||||
|
||||
## Already cleaned in simplify pass (2026-05-26)
|
||||
|
||||
- Extracted [`acp-stream.ts`](../apps/coder/src/services/acp-stream.ts) (shared ACP NDJSON bridge)
|
||||
- Deduped `mergeCommands` → [`provider-commands.ts`](../apps/coder/src/services/provider-commands.ts)
|
||||
- Parallel ACP probes + singleflight in [`provider-snapshot.ts`](../apps/coder/src/services/provider-snapshot.ts)
|
||||
- Removed dead `getPendingSessionId` from [`permission-waiter.ts`](../apps/coder/src/services/permission-waiter.ts)
|
||||
- CoderPane: WS message dedup, slash helpers, poll only when WS disconnected
|
||||
|
||||
---
|
||||
|
||||
## Skipped (needs product decision)
|
||||
|
||||
- **Task cancel → abort external ACP/PTY child** — `AbortController` in dispatcher not wired to cancel route
|
||||
- **Skip ACP cold probe when DB models fresh** — perf; changes snapshot semantics
|
||||
- **Unified `packages/types`** for provider snapshot JSON — Zod parity test may suffice
|
||||
742
docs/codecontext-ts-plan.md
Normal file
742
docs/codecontext-ts-plan.md
Normal file
@@ -0,0 +1,742 @@
|
||||
# Codecontext + TypeScript: recon and plan
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Author:** read-only recon, evidence-first
|
||||
|
||||
## Part A — Current codecontext usage in BooCode
|
||||
|
||||
### A1. Server-side synthesis pipeline
|
||||
|
||||
BooCode runs a **forced second-inference synthesis pass** after a model
|
||||
emits any of three codecontext tool calls. The list is hard-coded:
|
||||
|
||||
`/opt/boocode/apps/server/src/services/synthesisPipeline.ts:34-38`
|
||||
```ts
|
||||
export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
|
||||
'get_codebase_overview',
|
||||
'get_framework_analysis',
|
||||
'get_semantic_neighborhoods',
|
||||
]);
|
||||
```
|
||||
|
||||
The pipeline is triggered from the tool-phase, not by the model:
|
||||
`/opt/boocode/apps/server/src/services/inference/tool-phase.ts:200-279`.
|
||||
After tool-phase records the tool_call/tool_result rows it picks the first
|
||||
synth-eligible entry, expands the inline-truncated head via tmpfs
|
||||
(`readTruncation`), pulls top-N referenced files + project docs
|
||||
(BOOCHAT.md, AGENTS.md, CONTEXT.md, *roadmap*.md), token-budgets to
|
||||
32k chars/4 (`synthesisPipeline.ts:45-46`), streams a second model
|
||||
inference with a 90s timeout (`synthesisPipeline.ts:50`), and either
|
||||
emits a `kind='synthesis'` message-part or falls through to the
|
||||
recursive turn on failure (`synthesisPipeline.ts:250-272`).
|
||||
|
||||
The pipeline is **invoked once per turn that contains a SYNTHESIS_TOOLS
|
||||
call** — at most one synthesis pass per turn (the loop picks the first
|
||||
synth-eligible entry, `tool-phase.ts:256`).
|
||||
|
||||
The codecontext tools themselves are HTTP wrappers over the sidecar:
|
||||
`/opt/boocode/codecontext/shim.go:412-419` registers eight POST routes
|
||||
(`/v1/get_codebase_overview` … `/v1/get_framework_analysis`). The shim
|
||||
serialises calls under `callMu` and forwards JSON-RPC to a single
|
||||
`codecontext mcp` child (`shim.go:194`, `shim.go:328-333`). The child
|
||||
binary is built from `github.com/nmakod/codecontext` tag `v3.2.1`
|
||||
(`/opt/boocode/codecontext/Dockerfile:18-22`), NOT from the local fork at
|
||||
`/opt/forks/codecontext` (which is `github.com/nuthan-ms/codecontext`,
|
||||
fork go.mod: `/opt/forks/codecontext/go.mod:1`). Container reports
|
||||
`codecontext version dev` (recon: `docker exec boocode_codecontext
|
||||
codecontext --version` returned `codecontext version dev / Build Date:
|
||||
unknown / Git Commit: unknown`).
|
||||
|
||||
Wrapper boundaries:
|
||||
|
||||
- `/opt/boocode/apps/server/src/services/codecontext_client.ts:68-70`
|
||||
hard timeout `REQUEST_TIMEOUT_MS = 30_000`, inline truncation
|
||||
`TRUNCATION_LIMIT = 32_000`.
|
||||
- Same file lines 80-95: realpath project + target_dir, reject any
|
||||
target_dir that escapes the project root. The eight wrappers never
|
||||
pass `target_dir` (`callCodecontext` injects it server-side, line 99).
|
||||
- Lines 130-141 surface the upstream "content is empty" parser bug
|
||||
(issue #37) with an actionable hint pointing at `.codecontextignore`.
|
||||
|
||||
### A2. Agent-exposed tool surface
|
||||
|
||||
Source of truth: `/opt/boocode/data/AGENTS.md` (six agents) plus the
|
||||
`DEFAULT_TOOLS` fallback in
|
||||
`/opt/boocode/apps/server/src/services/agents.ts:19-20` (every tool in
|
||||
`ALL_TOOLS`).
|
||||
|
||||
Per-agent codecontext exposure (cited from
|
||||
`/opt/boocode/data/AGENTS.md:6,41,62,100,138,179`):
|
||||
|
||||
| Agent | Codecontext tools exposed |
|
||||
|---|---|
|
||||
| Code Reviewer (line 3) | get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, search_symbols, watch_changes |
|
||||
| Debugger (line 38) | same eight |
|
||||
| Refactorer (line 59) | same eight |
|
||||
| Architect (line 97) | same eight |
|
||||
| Security Auditor (line 135) | same eight |
|
||||
| Prompt Builder (line 176) | **none** — `tools: [view_file, list_dir, grep, find_files]` |
|
||||
|
||||
Every project-less or no-agent chat falls back to `DEFAULT_TOOLS` =
|
||||
`ALL_TOOLS` (all 21 tools including the eight codecontext ones)
|
||||
(`agents.ts:19-20,196`). The `BOOCODE_TOOLS` env var can narrow further
|
||||
via `resolveToolTier()` (`tools.ts:712-732`): `core` (4 tools, no
|
||||
codecontext) / `standard` (16, all eight codecontext) / `all` (21).
|
||||
`STANDARD_TOOL_NAMES` includes all eight codecontext tools
|
||||
(`tools.ts:719-732`).
|
||||
|
||||
The eight codecontext tool registrations live in `tools.ts:653-660` and
|
||||
are all marked read-only in `READ_ONLY_TOOL_NAMES` (`tools.ts:689-696`).
|
||||
|
||||
### A3. Actual usage (DB)
|
||||
|
||||
Tool-call frequency from `message_parts` (all-time; DB only has data
|
||||
back to 2026-05-22 today — see "Claims I did not verify" for the
|
||||
retention question):
|
||||
|
||||
Query: `SELECT payload->>'name', COUNT(*) FROM message_parts WHERE
|
||||
kind='tool_call' GROUP BY 1 ORDER BY 2 DESC`
|
||||
|
||||
| Tool | Calls | Chats |
|
||||
|---|---:|---:|
|
||||
| view_file | 129 | — |
|
||||
| grep | 81 | — |
|
||||
| list_dir | 78 | — |
|
||||
| find_files | 25 | — |
|
||||
| **get_codebase_overview** | **24** | 23 |
|
||||
| **search_symbols** | **8** | 5 |
|
||||
| ask_user_input | 5 | 3 |
|
||||
| `foo` (typo/invalid) | 4 | 2 |
|
||||
| view_truncated_output | 4 | 2 |
|
||||
| git_status | 3 | 2 |
|
||||
| **get_file_analysis** | **3** | 1 |
|
||||
| **get_framework_analysis** | **1** | 1 |
|
||||
| `([^` (typo/invalid) | 1 | 1 |
|
||||
|
||||
Codecontext-tool calls observed: **only 5 of 8** ever invoked
|
||||
(`get_codebase_overview`, `search_symbols`, `get_file_analysis`,
|
||||
`get_framework_analysis`, and `get_dependencies` does not appear).
|
||||
|
||||
**Never called** (in the recorded window): `get_dependencies`,
|
||||
`get_symbol_info`, `get_semantic_neighborhoods`, `watch_changes`.
|
||||
|
||||
Per-call args sample (`mp.created_at` desc, last 12 calls;
|
||||
recon-verified by query against message_parts):
|
||||
|
||||
- `get_codebase_overview` invoked ~9 times in a row with
|
||||
`{"include_stats":true}` — repeated overview fetches within minutes.
|
||||
- `search_symbols` examples: `{"limit":20,"query":"Kind"}`,
|
||||
`{"limit":20,"query":"SymbolKind"}`,
|
||||
`{"limit":20,"query":"Kind","framework_type":"typescript"}`.
|
||||
- `get_file_analysis` invoked 3 times in one chat with
|
||||
`file_path` = `apps/server/src/services/inference.ts`,
|
||||
`apps/server/src/services/inference/parts.ts`,
|
||||
`apps/server/src/services/system-prompt.ts` — **all three failed**
|
||||
with "File not found in graph" (see C3).
|
||||
|
||||
### A4. Hang and drift correlation
|
||||
|
||||
**Cohort analysis** (query against `messages` joined to chats that
|
||||
ever used any codecontext tool):
|
||||
|
||||
| Cohort | status | rows |
|
||||
|---|---|---:|
|
||||
| no_codecontext | complete | 24 |
|
||||
| no_codecontext | cancelled | 1 |
|
||||
| used_codecontext | complete | 191 |
|
||||
| used_codecontext | streaming | 2 |
|
||||
| used_codecontext | **failed** | **2** |
|
||||
|
||||
Two failed assistant messages, both in chats that used codecontext.
|
||||
Both have empty `content` — characteristic of a synth pass that aborted
|
||||
before any deltas streamed (see `synthesisPipeline.ts:278-303`,
|
||||
`markSynthFailed`). DB query:
|
||||
|
||||
```
|
||||
SELECT id, status, created_at, LEFT(content,200)
|
||||
FROM messages WHERE role='assistant' AND status IN ('failed','streaming')
|
||||
```
|
||||
returned two `failed` rows with empty content at 2026-05-22 18:43:39 and
|
||||
2026-05-22 19:59:56. The 18:43 failure correlates with the codecontext
|
||||
sidecar log line `2026/05/22 18:44:10.842554 get_framework_analysis
|
||||
target_dir=/opt/boocode duration_ms=30002 status=rpc_error` — a 30 s
|
||||
timeout (`codecontext_client.ts:70`) under a `get_framework_analysis`
|
||||
call (`synthesisPipeline.ts:34-38` would have triggered synthesis on
|
||||
success — failure path skipped synthesis and surfaced the error).
|
||||
|
||||
**Drift / format leakage:** the query
|
||||
`SELECT * FROM messages WHERE role='assistant' AND (content LIKE
|
||||
'%<invoke%' OR content LIKE '%<tool_call%')` returned 8 rows; manual
|
||||
review showed 7 are recon/discussion content where the model is
|
||||
quoting `<invoke>` as a *topic*, not actually emitting a tool call as
|
||||
text. **One real drift case** at 2026-05-22 19:05:03 — content begins
|
||||
"I need to investigate the codecontext fork to write this design
|
||||
document. Let me start by reading the key files.\n\n<invoke
|
||||
name=\"read_file\">…" — an Anthropic-format leak. This message is in a
|
||||
chat that did use codecontext, but the drift evidence is too thin
|
||||
(n=1) to claim a correlation.
|
||||
|
||||
## Part B — TypeScript parsing gap
|
||||
|
||||
### B1. TS-targeted workload
|
||||
|
||||
Per-language breakdown of codecontext calls that target a specific
|
||||
file or framework (DB query):
|
||||
|
||||
| Language hint | Calls |
|
||||
|---|---:|
|
||||
| no file_path (overview/framework/symbol search) | 33 |
|
||||
| ts/tsx | 3 |
|
||||
| (no other extension observed) | — |
|
||||
|
||||
The three TS-targeted calls were all `get_file_analysis` in a single
|
||||
chat: `inference.ts`, `inference/parts.ts`, `system-prompt.ts`. **All
|
||||
three failed** with `File not found in graph` (see C3 — relative path
|
||||
mishandling). One `search_symbols` call carried
|
||||
`framework_type=typescript` (Q="Kind").
|
||||
|
||||
So **TS is the actual workload** for narrow codecontext use; the rest
|
||||
is whole-repo overview/framework analysis with no specific language
|
||||
filter.
|
||||
|
||||
### B2. Symbol recovery quality
|
||||
|
||||
I called the live container against three load-bearing BooCode TS files
|
||||
and compared the symbol list against a manual grep of top-level
|
||||
declarations.
|
||||
|
||||
**File 1: `/opt/boocode/apps/server/src/types/api.ts` (371 lines)**
|
||||
|
||||
Manual count (grep `^(export )?(interface|type|const) `):
|
||||
- interfaces: 36
|
||||
- top-level types: 15
|
||||
- top-level consts: 5
|
||||
- total significant: 56
|
||||
|
||||
Codecontext output (live HTTP call to
|
||||
`http://codecontext:8080/v1/get_file_analysis`):
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "# File Analysis: ...\n**Lines:** 372\n**Symbols:** 10\n\n## Symbols\n\n- **PROJECT_STATUSES** () - Line 2\n- **PROJECT_STATUSES** () - Line 2\n- **CHAT_STATUSES** () - Line 91\n..."
|
||||
}
|
||||
```
|
||||
|
||||
Total reported: 10 symbols, all five `*_STATUSES` consts duplicated
|
||||
(line 2 appears twice, etc.). After regex-extracting names:
|
||||
|
||||
- Unique symbols reported by codecontext: 8 (5 *_STATUSES consts + 3
|
||||
header strings `Language:`/`Lines:`/`Symbols:`)
|
||||
- Interfaces / types found: **0 of 51**.
|
||||
- Symbol-recovery rate: **5/56 = ~9%** (only the const arrays the JS
|
||||
grammar understands).
|
||||
|
||||
Specific misses checked against the actual file
|
||||
(grep -nE on `/opt/boocode/apps/server/src/types/api.ts`):
|
||||
- Line 5 `export interface Project` — MISSED
|
||||
- Line 26 `export type SessionStatus` — MISSED
|
||||
- Line 28 `export interface Session` — MISSED
|
||||
- Line 47 `export type WorkspacePaneKind` — MISSED
|
||||
- All 36 interface declarations and 15 type aliases — MISSED.
|
||||
|
||||
**File 2: `/opt/boocode/apps/server/src/services/tools.ts` (763 lines)**
|
||||
|
||||
Manual count: 47 top-level decls
|
||||
(grep `^(export )?(interface|type|enum|namespace|const|function|class|async function) `).
|
||||
|
||||
Codecontext output: **112 symbols** reported (but many are noise:
|
||||
local function-scope variables, the literal token `"unknown"` from
|
||||
type cast positions, even raw labels like `out:`).
|
||||
|
||||
Python-extracted from result: 71 unique names. Cross-checked against
|
||||
20 significant TS exports the file declares:
|
||||
|
||||
- Found: `ListDirInput`, `READ_ONLY_TOOL_NAMES`, `CORE_TOOL_NAMES`,
|
||||
`STANDARD_TOOL_NAMES` (4 / 20)
|
||||
- **MISSED: `ToolDef`, `ViewFileInput`, `viewFile`, `listDir`, `grep`,
|
||||
`findFiles`, `viewTruncatedOutput`, `gitStatus`, `skillFind`,
|
||||
`skillUse`, `skillResource`, `askUserInput`, `ALL_TOOLS`,
|
||||
`TOOLS_BY_NAME`, `resolveToolTier`, `toolJsonSchemas`** — every
|
||||
exported `ToolDef<…>` named constant is missed because the JS
|
||||
grammar can't parse the TS type annotation `: ToolDef<…>` that
|
||||
precedes the `=` and bails out of recognising the const at
|
||||
top-level.
|
||||
- Symbol-recovery rate (significant): **4/20 = 20%**.
|
||||
|
||||
**File 3: `/opt/boocode/apps/server/src/services/inference/stream-phase.ts` (482 lines)**
|
||||
|
||||
Manual count: 5 top-level decls (2 are `export async function`,
|
||||
1 interface, 1 type, 1 const).
|
||||
|
||||
Codecontext output: 53 symbols extracted, but the first 20 are header
|
||||
strings (`Language:`, `Lines:`, `Symbols:`), imports (`api.js`,
|
||||
`model-context.js`, …), local function names from inside bodies
|
||||
(`toolNameById`, `out:`, `hasTools`), and string literals
|
||||
(`parts:`). Neither `streamCompletion` nor `executeStreamPhase` (the
|
||||
two `export async function` declarations at lines 145, 346) appear in
|
||||
the symbol list explicitly.
|
||||
|
||||
**Aggregate:** across the three files, codecontext recovers
|
||||
type/interface/enum symbols at effectively **0%**, and function/const
|
||||
symbols at roughly **20%**. The 9596-symbol whole-repo overview is
|
||||
heavily noise-padded. Generic type parameters and decorators were not
|
||||
checked individually because they're a strict subset of the
|
||||
already-broken case.
|
||||
|
||||
### B3. Fork status
|
||||
|
||||
**`docs/ts-bindings-design.md` does NOT exist.** Verified by
|
||||
`ls /opt/forks/codecontext/docs/ts-bindings-design.md` → `No such file
|
||||
or directory`. The `/opt/forks/codecontext/docs/` tree has 23 markdown
|
||||
files; none mention TypeScript bindings work (greps under
|
||||
`/opt/forks/codecontext/docs/` for `TypescriptLanguage|tree-sitter-tsx`
|
||||
returned nothing beyond a CodeContext example in `HLD.md:831` and
|
||||
config mentions in `ARCHITECTURE.md:297`).
|
||||
|
||||
**go.mod dependencies (`/opt/forks/codecontext/go.mod:5-18`):**
|
||||
- `github.com/tree-sitter/tree-sitter-javascript v0.23.1` (present)
|
||||
- `github.com/tree-sitter/tree-sitter-typescript` — **NOT present**.
|
||||
|
||||
**TS-as-JS fallback in `internal/parser/manager.go:72-79`:**
|
||||
```go
|
||||
// TypeScript - use JavaScript grammar as fallback until TypeScript bindings are fixed
|
||||
// Both JS and TS have similar syntax and this provides basic parsing capability
|
||||
tsLang := sitter.NewLanguage(javascript.Language())
|
||||
m.languages["typescript"] = tsLang
|
||||
|
||||
tsParser := sitter.NewParser()
|
||||
tsParser.SetLanguage(tsLang)
|
||||
m.parsers["typescript"] = tsParser
|
||||
```
|
||||
|
||||
The comment claims this provides "basic parsing capability". B2 shows
|
||||
that interface/type recovery is effectively zero — the JS grammar does
|
||||
not recognise `interface`, `type`, generic params, decorators, or even
|
||||
TS-typed const declarations.
|
||||
|
||||
**Downstream code IS prepared for TS-specific nodes.** In
|
||||
`internal/parser/manager.go:746-765` `nodeToSymbolJS` already has
|
||||
cases for `interface_declaration` and `type_alias_declaration`:
|
||||
```go
|
||||
case "interface_declaration", "interface":
|
||||
return &types.Symbol{Type: types.SymbolTypeInterface, ...}
|
||||
case "type_alias_declaration", "type_declaration":
|
||||
return &types.Symbol{Type: types.SymbolTypeType, ...}
|
||||
```
|
||||
These cases are dead code with the JS grammar — they only fire when
|
||||
the parser is the TypeScript grammar. The fork already has the symbol
|
||||
extraction wiring; it's just missing the grammar.
|
||||
|
||||
**`SymbolType` is open (string), not an iota** —
|
||||
`/opt/forks/codecontext/pkg/types/graph.go:14`:
|
||||
```go
|
||||
type SymbolType string
|
||||
```
|
||||
with constants like `SymbolTypeInterface`, `SymbolTypeType`,
|
||||
`SymbolTypeNamespace` already declared (`graph.go:16-48`). No code
|
||||
changes needed there to add TS-aware symbol types.
|
||||
|
||||
**Upstream `tree-sitter-typescript` Go bindings exist.** Context7 docs
|
||||
for `/tree-sitter/tree-sitter-typescript` show the Go package
|
||||
`github.com/tree-sitter/tree-sitter-typescript` exporting
|
||||
`LanguageTypescript()` and `LanguageTSX()`:
|
||||
```go
|
||||
typescript := sitter.NewLanguage(tree_sitter_typescript.LanguageTypescript())
|
||||
tsx := sitter.NewLanguage(tree_sitter_typescript.LanguageTSX())
|
||||
```
|
||||
(Context7 query `/tree-sitter/tree-sitter-typescript`,
|
||||
"Go bindings package name and how to import…", returned a working
|
||||
sample.)
|
||||
|
||||
**The fork (`/opt/forks/codecontext`) is not what runs in production.**
|
||||
The deployed image is built from `github.com/nmakod/codecontext` tag
|
||||
v3.2.1 (`/opt/boocode/codecontext/Dockerfile:18-22`). The fork is a
|
||||
separate working tree at `/opt/forks/codecontext` on
|
||||
`github.com/nuthan-ms/codecontext` (`/opt/forks/codecontext/go.mod:1`).
|
||||
Any TS-grammar work landing in either repo requires a Dockerfile
|
||||
update to point at the right source.
|
||||
|
||||
**Fork HEAD:** `ba6b94c 2025-09-01 12:43:09 +0530 Merge pull request
|
||||
#29 from nmakod/release-please--branches--main` — newer than the
|
||||
deployed v3.2.1 tag but on the same upstream lineage.
|
||||
|
||||
### B4. Existing TS-aware alternatives
|
||||
|
||||
Searches in `/opt/boocode`:
|
||||
|
||||
- `grep -rln 'ts-morph|@typescript/vfs|createCompilerHost'
|
||||
/opt/boocode/apps` → **no matches** in source (only types).
|
||||
- Only the `typescript` package is depended on
|
||||
(`/opt/boocode/package.json`, `/opt/boocode/apps/booterm/package.json`,
|
||||
`/opt/boocode/apps/server/package.json`,
|
||||
`/opt/boocode/apps/web/package.json` — each declares
|
||||
`"typescript": "^5.5.0"`). That's the tsc compiler, used for
|
||||
building, not for runtime symbol extraction.
|
||||
- No tool in `/opt/boocode/apps/server/src` parses TS at runtime for
|
||||
any reason other than what codecontext provides.
|
||||
|
||||
So BooCode has **no existing fallback** for TS symbol data: if
|
||||
codecontext can't extract it, nobody else does.
|
||||
|
||||
## Part C — Optimization opportunities
|
||||
|
||||
### C1. Tool surface review
|
||||
|
||||
Cross-referencing the agent whitelist (A2) with actual usage (A3):
|
||||
|
||||
| Tool | Exposed to 5 agents? | Calls observed | Recommendation |
|
||||
|---|---|---:|---|
|
||||
| get_codebase_overview | yes | 24 | **Keep** — load-bearing, synth-triggering |
|
||||
| search_symbols | yes | 8 | **Keep** — only viable TS query path |
|
||||
| get_file_analysis | yes | 3 | **Keep** but fix relative-path bug (C3) |
|
||||
| get_framework_analysis | yes | 1 | Low-use; **keep** for synth signalling |
|
||||
| get_dependencies | yes | **0** | **Demote** — unused, considered for removal |
|
||||
| get_symbol_info | yes | **0** | **Demote** — unused, considered for removal |
|
||||
| get_semantic_neighborhoods | yes | **0** | **Demote** — unused, considered for removal |
|
||||
| watch_changes | yes | **0** | **Remove** from agent whitelist — also pulled out of synthesis if currently kept |
|
||||
|
||||
`watch_changes` in particular is a state-changing async tool with no
|
||||
sensible LLM consumer (the model can't await fsnotify events). It
|
||||
should not be in the 5 agents' whitelists; the synthesis pipeline only
|
||||
calls 3 specific tools (`synthesisPipeline.ts:34-38`) so removing
|
||||
`watch_changes` from agent whitelists does not affect the pipeline.
|
||||
|
||||
`get_dependencies`, `get_symbol_info`, `get_semantic_neighborhoods`
|
||||
are credible tools but the model never reaches for them — likely a
|
||||
descriptions/discoverability issue. Either improve their tool
|
||||
descriptions (the `.description` strings registered in
|
||||
`tools/codecontext/*.ts`) or remove them from agent whitelists.
|
||||
|
||||
### C2. Latency and token cost
|
||||
|
||||
Latencies parsed from the codecontext sidecar access log
|
||||
(`docker logs boocode_codecontext --since 24h | grep duration_ms=`):
|
||||
|
||||
- Total calls observed: 40 in 24h
|
||||
- Total time: 610,404 ms
|
||||
- Avg: **15,260 ms per call**
|
||||
- Min: 1,379 ms
|
||||
- p50: 9,417 ms
|
||||
- p90: 27,611 ms
|
||||
- Max: 30,002 ms (= the 30 s rpc_error timeout)
|
||||
|
||||
Sampled MCP-server log lines confirm overview rebuilds cost 2–8 s on
|
||||
/opt/boocode (`6575 files, 115601 symbols, 1186758 chars markdown`
|
||||
in 8.22 s). The shim's per-tool log shows the analysis dominates;
|
||||
markdown serialization is sub-second.
|
||||
|
||||
**Synthesis pipeline expansion** (from `docker logs boocode`):
|
||||
|
||||
Five completed synthesis passes today, sample sizes:
|
||||
- `originalChars` (truncated head shipped to synth): **32,078** in
|
||||
every case (= the wrapper's 32 kB cap).
|
||||
- `fullChars` (full overview after re-expansion from tmpfs): 83,406 /
|
||||
83,408 / 83,410 / 97,283 / 97,464.
|
||||
|
||||
In other words, every overview is over the wrapper cap and synthesis
|
||||
always pays a tmpfs round-trip to recover the full content for
|
||||
reference-file extraction. The full content is *not* shipped to the
|
||||
synth model (the truncated head is — `synthesisPipeline.ts:141`), so
|
||||
the token-budget contract holds, but the synth still has to wait on
|
||||
the file I/O.
|
||||
|
||||
One synthesis timeout in the day (`synthesis pass timed out; falling
|
||||
through to recursive turn`, chatId a74bfecb…, toolName
|
||||
get_codebase_overview, 90 s after expansion completed — the synth
|
||||
inference itself was too slow). The retry inside the same chat then
|
||||
completed in 31 s with `files: 0` (no referenced files extracted),
|
||||
suggesting the timeout repeated until reference extraction was
|
||||
empty.
|
||||
|
||||
I have no cache-hit statistics to report — the shim does not log
|
||||
cache hits. The codecontext binary itself logs `Refreshing analysis
|
||||
for codebase overview…` on every call (`[MCP] Refreshing analysis…`
|
||||
appears for each `get_codebase_overview` in the sidecar log), so the
|
||||
analysis is rebuilt per call.
|
||||
|
||||
### C3. Failure modes
|
||||
|
||||
Sidecar errors in the last 7 days
|
||||
(`docker logs boocode_codecontext --since 168h | grep -E
|
||||
"status=tool_error|content is empty|panic"`):
|
||||
|
||||
1. **`content is empty` parser bug** — 2026-05-22 17:37:41 and
|
||||
17:43:41, both against `/opt/homelabhealth`, on
|
||||
`frontend/node_modules/hono/dist/adapter/aws-lambda/types.js`.
|
||||
The wrapper's `.codecontextignore` template installation
|
||||
(`codecontext_client.ts:30-52`) didn't help because the file is
|
||||
under `node_modules` which is supposedly in the template. Suggests
|
||||
either the template hadn't been copied yet or the template's
|
||||
ignore list doesn't cover the path. Each failed call cost ~25 s.
|
||||
2. **Relative-path failures** — 2026-05-22 17:56:51 through 17:57:07
|
||||
(three back-to-back), all `get_file_analysis`:
|
||||
```
|
||||
[MCP] ERROR: File not found in graph: apps/server/src/services/inference.ts (available files: 6575)
|
||||
```
|
||||
The wrapper resolves `target_dir` to an absolute realpath
|
||||
(`codecontext_client.ts:80-99`) but `file_path` is forwarded
|
||||
unchanged. The codecontext binary's file index is keyed on
|
||||
absolute paths (the 115,876-symbol overview reports absolute
|
||||
paths). The model passed `apps/server/src/services/inference.ts`
|
||||
and the binary couldn't find it. Each failure cost 8–24 s.
|
||||
3. **30 s rpc_error timeout** — 2026-05-22 18:44:10
|
||||
(get_framework_analysis) and 19:38:06 (search_symbols vs
|
||||
/opt/forks/codecontext). The shim's per-call context timeout is
|
||||
60 s (`shim.go:325`) but the wrapper aborts at 30 s
|
||||
(`codecontext_client.ts:70`), so the client gives up before the
|
||||
shim does — the call still runs to completion on the codecontext
|
||||
side, wasting CPU.
|
||||
4. **Panic in `searchSymbols`** — concurrent map iteration crash in
|
||||
`internal/mcp/server.go:1305` (`getFilePathForSymbol`) under
|
||||
`matchesFramework`, captured in
|
||||
`docker logs boocode_codecontext --since 24h`:
|
||||
```
|
||||
internal/runtime/maps.fatal(...)
|
||||
github.com/nuthan-ms/codecontext/internal/mcp.(*CodeContextMCPServer).getFilePathForSymbol(...)
|
||||
/build/codecontext/internal/mcp/server.go:1305
|
||||
```
|
||||
This is an upstream bug in v3.2.1 — concurrent map access without
|
||||
a lock. The shim's `callMu` serialises *its* calls but the
|
||||
codecontext binary itself appears to have internal concurrency
|
||||
that hits this.
|
||||
|
||||
**Pattern:** the 2 failed assistant messages in A4 align with the 30 s
|
||||
rpc_error timeout (18:44:10) and one other failure window. Failed
|
||||
turns leave empty `content` because synthesis aborts before any
|
||||
deltas — the model never sees the codecontext error.
|
||||
|
||||
## Part D — Plan
|
||||
|
||||
### D1. Tool surface decisions
|
||||
|
||||
**Title:** Trim agent codecontext exposure to the four tools that earn
|
||||
their keep; demote the rest until evidence justifies them.
|
||||
|
||||
**Why:** A3 shows 4 of 8 codecontext tools have zero observed calls,
|
||||
and `watch_changes` (a fsnotify-coupled tool) has no LLM consumer.
|
||||
The synthesis pipeline only auto-triggers on three tools
|
||||
(`synthesisPipeline.ts:34-38`), so removing tools from agent
|
||||
whitelists does not affect the server-side synth path.
|
||||
|
||||
**Scope:** edit `/opt/boocode/data/AGENTS.md` lines 6, 41, 62, 100,
|
||||
138 (Code Reviewer, Debugger, Refactorer, Architect, Security
|
||||
Auditor) to drop `get_dependencies`, `get_symbol_info`,
|
||||
`get_semantic_neighborhoods`, `watch_changes` from each `tools:`
|
||||
array. Roughly 5 line edits.
|
||||
|
||||
**Risk:** if there's a legitimate workflow not yet captured in 24 h
|
||||
of DB data, dropping these tools removes that affordance. Mitigation:
|
||||
keep them registered in `tools.ts` (the server-side wrappers stay) so
|
||||
the synth pipeline can still call them if `SYNTHESIS_TOOLS` expands
|
||||
later, and so the `BOOCODE_TOOLS=standard` tier continues to expose
|
||||
them via the tier filter. Tests: `agents.test.ts`, `tools.test.ts`,
|
||||
any agent-roundtrip tests.
|
||||
|
||||
**Effort:** 30 min.
|
||||
|
||||
**Sequence:** standalone. Unblocks D3 (smaller tool list = smaller
|
||||
system prompt = better prompt-cache stability per `tools.ts:629-632`).
|
||||
|
||||
### D2. TypeScript support path
|
||||
|
||||
**Title:** Narrow the TS fork scope to "interfaces, types, enums, top-
|
||||
level typed consts" — defer generics and decorators.
|
||||
|
||||
**Why:** Evidence from B1 (3 TS-targeted calls — all
|
||||
`get_file_analysis` — and 1 `search_symbols framework_type=typescript`)
|
||||
shows TS is in the workload but at low volume. Evidence from B2
|
||||
shows symbol recovery is **~0% for interfaces/types and ~20% for
|
||||
typed consts**. That gap is what actually breaks model behaviour:
|
||||
when the model asks `get_file_analysis` for `api.ts` (which IS what
|
||||
happened today) it gets 10 noise symbols and no `interface Project`,
|
||||
`interface Session`, `type SessionStatus`. The narrow scope
|
||||
(declarations only; skip generics, JSX, decorators) covers ~90% of
|
||||
the recovered-symbol gap and is achievable with one new dependency
|
||||
and one parser-init change.
|
||||
|
||||
**Scope:**
|
||||
1. `/opt/forks/codecontext/go.mod`: add
|
||||
`github.com/tree-sitter/tree-sitter-typescript v0.23.x` to the
|
||||
`require` block.
|
||||
2. `/opt/forks/codecontext/internal/parser/manager.go:72-79`:
|
||||
replace the JS-fallback init with
|
||||
```go
|
||||
typescript "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
|
||||
...
|
||||
tsLang := sitter.NewLanguage(typescript.LanguageTypescript())
|
||||
m.languages["typescript"] = tsLang
|
||||
tsxLang := sitter.NewLanguage(typescript.LanguageTSX())
|
||||
m.languages["tsx"] = tsxLang
|
||||
```
|
||||
Plus parser registrations. `nodeToSymbolJS` already handles
|
||||
`interface_declaration` and `type_alias_declaration` (lines
|
||||
746-765) — no extraction code changes needed for the narrow scope.
|
||||
3. `/opt/forks/codecontext/internal/parser/manager.go:357-395`
|
||||
`detectLanguage` (skim verified to live around line 357): ensure
|
||||
`.tsx` maps to `"tsx"` not `"typescript"`. Likely already correct
|
||||
— verify.
|
||||
4. Tests in `internal/parser/` — add TS-grammar fixtures (a small
|
||||
`.ts` file with interface, type, enum) to assert recovery.
|
||||
5. Update `/opt/boocode/codecontext/Dockerfile:18-22` to clone from
|
||||
the fork instead of `github.com/nmakod/codecontext` v3.2.1 once
|
||||
the TS-grammar branch lands. **Or** PR the change upstream first
|
||||
if `nmakod/codecontext` is open to it.
|
||||
6. Drop the fork's own `tree-sitter-javascript` dependency? No —
|
||||
`tree-sitter-typescript` Go binding is separate and the JS
|
||||
grammar is still needed for `.js`/`.jsx` files.
|
||||
|
||||
Rough LoC: ~20 lines in manager.go, +1 line go.mod, +1 import, +1
|
||||
language-detect entry; ~50 lines of tests; ~5 lines in Dockerfile.
|
||||
|
||||
**Risk:** TS grammar parses superset syntax; some TS files may now
|
||||
hit `ERROR` nodes the JS grammar happily accepted. Mitigate by
|
||||
keeping the JS grammar registered for `.js`/`.jsx` and not changing
|
||||
JS handling. Regression risk lives in the codecontext-binary CI
|
||||
(JS+TS combined corpus) — verify their existing tests still pass.
|
||||
Tests to add: a fixture file containing each B2 missed symbol and a
|
||||
manager_test that asserts the symbols are recovered.
|
||||
|
||||
**Effort:** Phase A (grammar swap + tests + Dockerfile pin): 90 min
|
||||
once a build-and-test loop is set up in the fork.
|
||||
|
||||
**Sequence:** Blocked on a decision about whether to PR upstream
|
||||
(`nmakod/codecontext`) or fork-and-deploy (`nuthan-ms/codecontext`).
|
||||
Unblocks D3 (cleaner TS results = smaller noise in synthesis output
|
||||
= smaller token cost).
|
||||
|
||||
**Decision:** **Narrow**, not "drop" and not "full TS support". Drop
|
||||
is wrong because TS *is* the workload (A2 + B1 show every agent and
|
||||
the codebase under analysis are TS-heavy). Full Phase 3-4 TS support
|
||||
(generics, decorators, full type queries) is overkill for current
|
||||
usage — interface/type/enum recovery captures the model's actual
|
||||
need.
|
||||
|
||||
### D3. Synthesis pipeline optimizations
|
||||
|
||||
**Title:** Reduce per-turn codecontext latency and cache the overview.
|
||||
|
||||
**Why:** C2 shows avg 15.2 s per codecontext call and an overview
|
||||
that rebuilds on every call. Synthesis always pays the 30 s wrapper
|
||||
timeout when the codecontext binary panics (C3 case 4) or hangs.
|
||||
|
||||
**Three sub-items:**
|
||||
|
||||
D3a. **Cache the overview at the shim layer.** The shim already
|
||||
serialises calls under `callMu` (`shim.go:74-77`). Add a per-
|
||||
`target_dir` overview cache keyed on a directory-mtime hash, TTL ~60s.
|
||||
Sub-second cache hits for repeated `get_codebase_overview` calls
|
||||
(today shows ~9 in a single chat over a few minutes).
|
||||
- File: `/opt/boocode/codecontext/shim.go`
|
||||
- LoC: ~80
|
||||
- Effort: 90 min
|
||||
- Risk: invalidation. Use the fastest cheap invalidator (mtime of
|
||||
target_dir + a hash of the file count via `os.ReadDir`). On any
|
||||
doubt, bypass cache.
|
||||
|
||||
D3b. **Align wrapper and shim timeouts.** Wrapper 30 s
|
||||
(`codecontext_client.ts:70`), shim ctx 60 s (`shim.go:325`). The
|
||||
mismatch wastes CPU when the wrapper gives up but the shim keeps
|
||||
running. Either drop the shim ctx to 30 s, or raise the wrapper
|
||||
to 60 s (depending on which budget is right). Recommended: align
|
||||
both to 45 s, abort upstream on wrapper cancel.
|
||||
- LoC: 2 lines
|
||||
- Effort: 30 min
|
||||
|
||||
D3c. **Fix the relative-path bug in `get_file_analysis`.** The
|
||||
wrapper resolves `target_dir` but not `file_path`. Three failures
|
||||
in one chat today wasted 48 s of CPU. Fix:
|
||||
- File: `/opt/boocode/apps/server/src/services/tools/codecontext/get_file_analysis.ts`
|
||||
(and possibly the shared client at `codecontext_client.ts`).
|
||||
- Have the wrapper resolve `file_path` against the realpath'd
|
||||
project root before forwarding, mirroring `target_dir`. Error out
|
||||
if the resolved path doesn't start with the project root.
|
||||
- LoC: ~20
|
||||
- Effort: 60 min
|
||||
- Risk: low — the model loses no affordance; absolute and relative
|
||||
both work.
|
||||
- Tests: `codecontext_client.test.ts`.
|
||||
|
||||
**Sequence:** D3c is independent and high-ROI. D3a depends on
|
||||
nothing. D3b is independent. Recommended order: D3c → D3b → D3a.
|
||||
|
||||
### D4. Removal candidates
|
||||
|
||||
1. **`watch_changes` agent exposure** (A3 + A2). Server-side handler
|
||||
stays for completeness; it should not appear in agent
|
||||
`tools:` arrays. Edit `/opt/boocode/data/AGENTS.md` lines 6, 41,
|
||||
62, 100, 138.
|
||||
2. **The dead "csharp" comment-out block** in
|
||||
`/opt/forks/codecontext/internal/parser/manager.go:146-152` —
|
||||
delete-on-touch when D2 lands; not part of D2's core scope.
|
||||
3. **The 3 zero-use codecontext tool exposures** —
|
||||
`get_dependencies`, `get_symbol_info`, `get_semantic_neighborhoods`.
|
||||
Same surgical edits as item 1. Consider keeping
|
||||
`get_dependencies` on the Refactorer because the agent
|
||||
description explicitly invokes "Use get_dependencies to map call
|
||||
sites" (`AGENTS.md:92-93`); if the model isn't using it despite
|
||||
the system-prompt nudge, the description in
|
||||
`tools/codecontext/get_dependencies.ts` likely needs the same
|
||||
verb-forward rewrite.
|
||||
|
||||
## Claims I did not verify
|
||||
|
||||
- **DB retention horizon.** All `message_parts` rows are dated
|
||||
2026-05-22. That could mean (a) the DB was wiped today, (b) the
|
||||
schema/path moved today, or (c) the project is brand-new and 24 h
|
||||
is genuinely the full history. The CLAUDE.md project context
|
||||
references "v1.13.15-codecontext-synth" which is recent. To verify:
|
||||
`docker exec boocode_db psql -U boocode -d boocode -c "SELECT
|
||||
MIN(created_at), MAX(created_at), COUNT(*) FROM messages;"` then
|
||||
cross-check against the BooCode roadmap's release dates. The 30-day
|
||||
window in A3's query may simply not have older data to find.
|
||||
- **Whether `nmakod/codecontext` v3.2.1 hosts the same
|
||||
`nodeToSymbolJS` switch I read in the fork.** The fork at
|
||||
`/opt/forks/codecontext` is `nuthan-ms/codecontext` per
|
||||
go.mod. The deployed v3.2.1 is `nmakod/codecontext`. The Dockerfile
|
||||
comment (`/opt/boocode/codecontext/Dockerfile:13-16`) says the
|
||||
module path differs but "the tagged v3.2.1 source tree is the same
|
||||
either way." To verify, clone
|
||||
`https://github.com/nmakod/codecontext` at tag v3.2.1 and diff
|
||||
`internal/parser/manager.go` against the fork — outside this
|
||||
recon's read-only scope.
|
||||
- **Whether `tree-sitter-typescript v0.23.x` Go bindings actually
|
||||
build under the fork's `go 1.24.5` + Tree-sitter `v0.25.0`
|
||||
combination.** Context7 docs confirm the *API exists*. Confirm by
|
||||
`go get github.com/tree-sitter/tree-sitter-typescript@latest`
|
||||
followed by `go build ./...` in a scratch worktree.
|
||||
- **Whether the codecontext panic in `searchSymbols` is reproducible
|
||||
on `/opt/boocode` or only on `/opt/forks/codecontext`** (the panic
|
||||
was captured against target_dir `/opt/forks/codecontext`). Reproduce
|
||||
via `docker exec boocode_codecontext wget -qO -
|
||||
--post-data='{"target_dir":"/opt/boocode","query":"foo","limit":10}'
|
||||
--header='Content-Type: application/json'
|
||||
http://localhost:8080/v1/search_symbols`.
|
||||
- **Cache hit rate of codecontext analysis (per call vs reused).**
|
||||
The MCP-server log line `Refreshing analysis for codebase
|
||||
overview…` suggests rebuild-every-call, but I did not confirm by
|
||||
reading the codecontext source — only the deployed binary's log
|
||||
output. To verify, read
|
||||
`/opt/forks/codecontext/internal/mcp/server.go` around the
|
||||
`Refreshing analysis…` log lines.
|
||||
- **Drift correlation strength.** N=1 confirmed drift case is too
|
||||
small to call a correlation with codecontext use. To raise the
|
||||
signal: extend retention, re-query after a week of synthetic
|
||||
load with and without codecontext tools.
|
||||
- **Whether the synth pipeline's `truncated head only` ships fewer
|
||||
tokens than a full inlined codecontext result would.** Today's
|
||||
budget contract assumes yes (`synthesisPipeline.ts:138-145`
|
||||
comment "Truncated head only — full content was used for
|
||||
reference extraction above"). To verify: instrument the
|
||||
per-pass `promptTokens` and compare against a one-off pass with
|
||||
the full content.
|
||||
- **The Architect/Code-Reviewer agents' system-prompt copy versus
|
||||
actual tool usage.** AGENTS.md text claims agents will "Use
|
||||
get_dependencies to map call sites" (line 92) and "Use
|
||||
get_semantic_neighborhoods to find related components"
|
||||
(line 132), but A3 shows neither is called. To verify whether the
|
||||
model is ignoring the prompt or whether these agents simply
|
||||
aren't being invoked, query
|
||||
`SELECT s.name, COUNT(*) FROM sessions s JOIN chats c ON
|
||||
c.session_id=s.id JOIN messages m ON m.chat_id=c.id WHERE
|
||||
m.role='assistant' GROUP BY 1 ORDER BY 2 DESC;` and compare
|
||||
named agents to chat counts.
|
||||
381
docs/superpowers/plans/2026-05-25-provider-picker-backend.md
Normal file
381
docs/superpowers/plans/2026-05-25-provider-picker-backend.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# BooCoder Provider Picker — Backend (Steps 1–3)
|
||||
|
||||
> **Superseded:** Shipped as `v2.1.0-provider-picker` (2026-05-25). Agent discovery uses direct `exec()` on the host, not SSH. See `CHANGELOG.md` and `apps/coder/src/services/agent-probe.ts` for current implementation.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Expose a `GET /api/providers` endpoint on BooCoder (port 9502) that returns all available providers with their model lists, so the frontend can build a two-level provider → model picker.
|
||||
|
||||
**Architecture:** A static provider registry maps agent names to their metadata (transport, model source). The existing `agent-probe.ts` is extended to discover models for each agent and persist them in a new `models` JSONB column on `available_agents`. A new `/api/providers` route merges the registry with DB state and llama-swap models to produce the response.
|
||||
|
||||
**Tech Stack:** Fastify, postgres (porsager), Zod, direct host exec for agent discovery (historical plan referenced SSH — superseded at v2.1.0).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `apps/coder/src/services/provider-registry.ts` | Static provider metadata (label, transport, model source) |
|
||||
| Modify | `apps/coder/src/schema.sql` | Add `models`, `label`, `transport` columns to `available_agents` |
|
||||
| Modify | `apps/coder/src/services/agent-probe.ts` | Discover models per agent, persist to DB |
|
||||
| Create | `apps/coder/src/routes/providers.ts` | `GET /api/providers` route |
|
||||
| Modify | `apps/coder/src/index.ts` | Register providers route |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Provider Registry
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/coder/src/services/provider-registry.ts`
|
||||
|
||||
- [ ] **Step 1: Create the provider registry**
|
||||
|
||||
```typescript
|
||||
// apps/coder/src/services/provider-registry.ts
|
||||
|
||||
export interface ProviderDef {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: 'native' | 'acp' | 'pty';
|
||||
modelSource: 'llama-swap' | 'static';
|
||||
staticModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export const PROVIDERS: ProviderDef[] = [
|
||||
{
|
||||
name: 'boocode',
|
||||
label: 'BooCoder',
|
||||
transport: 'native',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'goose',
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'claude',
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
staticModels: [
|
||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'qwen',
|
||||
label: 'Qwen Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
// Models discovered at probe time from ~/.qwen/settings.json on host
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20`
|
||||
Expected: No errors from provider-registry.ts
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Schema Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/coder/src/schema.sql`
|
||||
|
||||
- [ ] **Step 1: Back up the schema file**
|
||||
|
||||
Run: `cp apps/coder/src/schema.sql apps/coder/src/schema.sql.bak-$(date +%Y%m%d)`
|
||||
|
||||
- [ ] **Step 2: Add columns to available_agents**
|
||||
|
||||
Append to the end of `apps/coder/src/schema.sql`:
|
||||
|
||||
```sql
|
||||
-- v2.1.0: provider picker — extend available_agents with model discovery.
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify schema applies cleanly**
|
||||
|
||||
This can't be tested locally without a DB connection. The schema is idempotent (`ADD COLUMN IF NOT EXISTS`), so it's safe to apply on startup. Verify syntax by reading the file.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extend agent-probe for model discovery
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/coder/src/services/agent-probe.ts`
|
||||
|
||||
- [ ] **Step 1: Import the provider registry**
|
||||
|
||||
Add at the top of `agent-probe.ts`:
|
||||
|
||||
```typescript
|
||||
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace KNOWN_AGENTS with registry-driven list**
|
||||
|
||||
Replace the `KNOWN_AGENTS` array and its type with a derivation from the provider registry:
|
||||
|
||||
```typescript
|
||||
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
|
||||
name,
|
||||
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
|
||||
}));
|
||||
```
|
||||
|
||||
This preserves the same shape the rest of `probeAgents` expects while deriving `supportsAcp` from the registry's `transport` field. `pi` is dropped (no provider def, not actively used). `boocode` is excluded (native — no binary to probe on host).
|
||||
|
||||
- [ ] **Step 3: Add model discovery after the existing version check**
|
||||
|
||||
Inside the `for (const agent of KNOWN_AGENTS)` loop, after the ACP check block and before the UPSERT, add model discovery:
|
||||
|
||||
```typescript
|
||||
// Discover models for this agent
|
||||
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 catResult = await sshExec('cat ~/.qwen/settings.json', { timeoutMs: 10_000 });
|
||||
if (catResult.exitCode === 0 && catResult.stdout.trim()) {
|
||||
const settings = JSON.parse(catResult.stdout) 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 — fall back to empty
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the UPSERT to include new columns**
|
||||
|
||||
Replace the existing UPSERT statement with:
|
||||
|
||||
```typescript
|
||||
const label = providerDef?.label ?? agent.name;
|
||||
const transport = providerDef?.transport ?? 'pty';
|
||||
|
||||
await sql`
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${JSON.stringify(models)}::jsonb, ${label}, ${transport})
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
install_path = EXCLUDED.install_path,
|
||||
version = EXCLUDED.version,
|
||||
supports_acp = EXCLUDED.supports_acp,
|
||||
last_probed_at = EXCLUDED.last_probed_at,
|
||||
models = EXCLUDED.models,
|
||||
label = EXCLUDED.label,
|
||||
transport = EXCLUDED.transport
|
||||
`;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update the log line to include model count**
|
||||
|
||||
Replace the existing log.info with:
|
||||
|
||||
```typescript
|
||||
log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found on host');
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20`
|
||||
Expected: No errors
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Goose PTY dispatch
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/coder/src/services/pty-dispatch.ts`
|
||||
|
||||
- [ ] **Step 1: Add goose case to buildAgentCommand**
|
||||
|
||||
In `pty-dispatch.ts`, replace the goose case (line ~61):
|
||||
|
||||
```typescript
|
||||
case 'goose':
|
||||
return model
|
||||
? `goose run --text '${escapedTask}' --model '${model}'`
|
||||
: `goose run --text '${escapedTask}'`;
|
||||
```
|
||||
|
||||
Note: `goose run --text` is the non-interactive execution flag. If goose's actual CLI differs, the dispatch will fail with a nonzero exit code and the task will be marked `failed` — no silent corruption.
|
||||
|
||||
- [ ] **Step 2: Update the module docstring**
|
||||
|
||||
Replace `goose: stub (not yet supported)` with `goose: \`goose run --text <task>\` (non-interactive)` in the header comment.
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20`
|
||||
Expected: No errors
|
||||
|
||||
---
|
||||
|
||||
### Task 5: GET /api/providers Route
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/coder/src/routes/providers.ts`
|
||||
- Modify: `apps/coder/src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create the providers route**
|
||||
|
||||
```typescript
|
||||
// apps/coder/src/routes/providers.ts
|
||||
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<ProviderModel[]> {
|
||||
try {
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||
if (!res.ok) return [];
|
||||
const parsed = (await res.json()) as { data?: LlamaSwapModel[] };
|
||||
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get('/api/providers', async (_req, _reply) => {
|
||||
// Fetch llama-swap models (shared by boocode, opencode, goose)
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
|
||||
// Fetch installed agents from DB
|
||||
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null }[]>`
|
||||
SELECT name, models, label, transport 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 = [];
|
||||
}
|
||||
|
||||
result.push({
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport: agentRow?.transport ?? provider.transport,
|
||||
installed,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route in index.ts**
|
||||
|
||||
In `apps/coder/src/index.ts`, add the import near the other route imports (around line 28):
|
||||
|
||||
```typescript
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
```
|
||||
|
||||
Add the registration call after the other `register*Routes` calls (around line 148):
|
||||
|
||||
```typescript
|
||||
registerProviderRoutes(app, sql, config);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker compose build --no-cache boocode && docker compose up -d
|
||||
```
|
||||
|
||||
Then verify:
|
||||
```bash
|
||||
curl http://100.114.205.53:9502/api/providers | jq .
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
```json
|
||||
[
|
||||
{ "name": "boocode", "label": "BooCoder", "transport": "native", "installed": true, "models": [...] },
|
||||
{ "name": "opencode", ... },
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint Verification
|
||||
|
||||
After Task 5, report:
|
||||
|
||||
1. `curl http://100.114.205.53:9502/api/providers` output
|
||||
2. `available_agents` schema after migration: `psql -h localhost -p 5500 -U boocode -d boochat -c '\d available_agents'`
|
||||
3. Any issues with qwen model discovery from `~/.qwen/settings.json`
|
||||
|
||||
**Do NOT proceed to frontend (Step 4 in the spec) without confirming the API works.**
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Per-batch documentation convention adopted v1.13.15-openspec.
|
||||
|
||||
**Agent entry point:** `AGENTS.md` at repo root. **Architecture diagram:** `docs/ARCHITECTURE.md`.
|
||||
|
||||
Lift source: Fission-AI/OpenSpec directory layout. **No CLI dependency** — just
|
||||
the folder shape. Full OpenSpec lifecycle adoption is a future v1.14+ batch.
|
||||
|
||||
|
||||
4
openspec/changes/archived/v1.13.12-skills-audit.md
Normal file
4
openspec/changes/archived/v1.13.12-skills-audit.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# v1.13.12-skills-audit
|
||||
|
||||
**Status:** Shipped. Archived.
|
||||
|
||||
4
openspec/changes/archived/v1.13.15-codecontext-synth.md
Normal file
4
openspec/changes/archived/v1.13.15-codecontext-synth.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# v1.13.15-codecontext-synth
|
||||
|
||||
**Status:** Shipped. Archived.
|
||||
|
||||
4
openspec/changes/archived/v1.13.17-cross-repo-reads.md
Normal file
4
openspec/changes/archived/v1.13.17-cross-repo-reads.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# v1.13.17-cross-repo-reads
|
||||
|
||||
**Status:** Shipped. Archived.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# v1.13.18-codecontext-file-path
|
||||
|
||||
**Status:** Shipped. Archived.
|
||||
|
||||
4
openspec/changes/archived/v1.13.20-drop-legacy-cols.md
Normal file
4
openspec/changes/archived/v1.13.20-drop-legacy-cols.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# v1.13.20-drop-legacy-cols
|
||||
|
||||
**Status:** Shipped. Archived.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user