Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53c6d6cb9 | |||
| 3d6055518b | |||
| 752ea74f43 | |||
| 73b53089b0 | |||
| 457c59fb06 | |||
| 78455b7efc | |||
| d2108b2f8d | |||
| ce31577d1e | |||
| 006226cce5 | |||
| 62d818af23 | |||
| 531d39ace9 | |||
| f2974d6887 | |||
| 29c7d051b6 | |||
| d27a977d59 | |||
| 5692e99a5d | |||
| f4a97808ad |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ secrets/
|
|||||||
data/*
|
data/*
|
||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
|
!data/mcp.json
|
||||||
|
|||||||
@@ -39,6 +39,13 @@
|
|||||||
|
|
||||||
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
||||||
|
|
||||||
|
## Verification discipline
|
||||||
|
|
||||||
|
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
||||||
|
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||||
|
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||||
|
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Codecontext re-analyzes the project graph on each call against a different target_dir. First call to a new project may take 1-3 seconds; subsequent calls to the same project return in ~10ms.
|
- Codecontext re-analyzes the project graph on each call against a different target_dir. First call to a new project may take 1-3 seconds; subsequent calls to the same project return in ~10ms.
|
||||||
|
|||||||
44
BOOCODER.md
44
BOOCODER.md
@@ -1,27 +1,39 @@
|
|||||||
# BooCoder
|
# BooCoder — Container Guidance
|
||||||
|
|
||||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||||
|
|
||||||
## Capabilities
|
## You can
|
||||||
|
|
||||||
- Everything in `BOOCHAT.md`
|
- Read files (view_file, list_dir, grep, find_files)
|
||||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
|
||||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
- Apply pending changes to disk (apply_pending)
|
||||||
|
- Revert applied changes (rewind)
|
||||||
|
- Dispatch tasks to external agents (dispatch_external_agent)
|
||||||
|
- Use MCP tools from configured servers
|
||||||
|
|
||||||
## Constraints
|
## You cannot
|
||||||
|
|
||||||
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
|
- Write outside the project root (path-guard enforced)
|
||||||
- `run_command` executes inside the session sandbox, not the host
|
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
|
||||||
- No git commits, pushes, or pulls — Sam owns those
|
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
|
||||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
- Push to git remotes
|
||||||
|
- Access the internet except via configured MCP servers
|
||||||
|
|
||||||
|
## Pending changes discipline
|
||||||
|
|
||||||
|
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Show a diff preview before any write
|
- Show diffs clearly. Explain what you're changing and why.
|
||||||
- Group related edits into a single `/apply` batch
|
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||||
- If a tool fails, surface the error verbatim — don't paper over it
|
- If uncertain about scope, use smaller edits and verify between steps.
|
||||||
|
- Cite file paths + line numbers for context.
|
||||||
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
||||||
|
|
||||||
## Convention: rules vs recipes
|
## Verification discipline
|
||||||
|
|
||||||
Always-true rules live here, in `BOOCHAT.md`, and in `CLAUDE.md` (100% present each turn). On-demand recipes live in `/data/skills/` (roughly 6% invoke rate in multi-turn per Codeminer42, 2026). Don't file workflow rules as skills — they misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
|
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
||||||
|
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||||
|
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||||
|
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## v1.15.0-mcp-multi — 2026-05-24
|
||||||
|
|
||||||
|
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
|
||||||
|
|
||||||
|
## v1.14.1-mcp-poc — 2026-05-23
|
||||||
|
|
||||||
|
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
|
||||||
|
|
||||||
|
## v1.14.0-outer-loop — 2026-05-23
|
||||||
|
|
||||||
|
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. A step is one stream-and-tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. `MAX_STEPS = 200` is the hard ceiling (4x the old effective limit from budget); per-agent `steps:` field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
|
||||||
|
|
||||||
## v1.13.20-drop-legacy-cols — 2026-05-23
|
## v1.13.20-drop-legacy-cols — 2026-05-23
|
||||||
|
|
||||||
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
|
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -46,7 +46,7 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
|||||||
- **Zod** for request validation and config parsing.
|
- **Zod** for request validation and config parsing.
|
||||||
|
|
||||||
Key services:
|
Key services:
|
||||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
|
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase → returns `ToolPhaseResult`; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope populated from loop locals each iteration; reset in `runInference` at user-message boundary. The outer loop in `runAssistantTurn` (v1.14.0) runs `while (stepNumber < effectiveCap)` where `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` field in AGENTS.md frontmatter. `steps: 0` means text-only (no tool execution). Step-cap hit writes a `cap_hit` sentinel so `CapHitSentinel.tsx` renders it.
|
||||||
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
||||||
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||||
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||||
@@ -69,6 +69,14 @@ Key services:
|
|||||||
|
|
||||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||||
|
|
||||||
|
### BooCoder (`apps/coder/src/`)
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
- `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 (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
|
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
|
||||||
@@ -105,14 +113,16 @@ Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). v1.12.1 m
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
|
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
|
||||||
|
|
||||||
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
|
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
|
||||||
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist).
|
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
|
||||||
|
|
||||||
|
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -134,6 +144,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
||||||
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
||||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
||||||
|
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
|
||||||
|
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
|
||||||
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@@ -156,3 +168,5 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
||||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||||
|
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||||
|
- **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`.
|
||||||
|
|||||||
35
apps/coder/Dockerfile
Normal file
35
apps/coder/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/server/package.json ./apps/server/
|
||||||
|
COPY apps/coder/package.json ./apps/coder/
|
||||||
|
COPY apps/coder/web/package.json ./apps/coder/web/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build server first (coder depends on it via workspace dep for types + inference)
|
||||||
|
COPY apps/server ./apps/server
|
||||||
|
RUN pnpm -C apps/server build
|
||||||
|
|
||||||
|
COPY apps/coder ./apps/coder
|
||||||
|
RUN pnpm -C apps/coder/web build
|
||||||
|
RUN pnpm -C apps/coder build
|
||||||
|
|
||||||
|
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git openssh-client && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /out/coder ./
|
||||||
|
COPY --from=builder /build/apps/coder/web/dist ./web
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
30
apps/coder/package.json
Normal file
30
apps/coder/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/coder",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.22.1",
|
||||||
|
"@boocode/server": "workspace:*",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"postgres": "^3.4.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/coder/src/config.ts
Normal file
45
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// BooCoder's config is a superset of the server's Config type so it can be
|
||||||
|
// passed directly into the inference runner's InferenceContext. Fields the
|
||||||
|
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
|
||||||
|
// default to values that satisfy the server's Zod schema without BooCoder
|
||||||
|
// needing to supply them in its environment.
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
LLAMA_SWAP_URL: z.string().url(),
|
||||||
|
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||||
|
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||||
|
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||||
|
LOG_LEVEL: z.string().default('info'),
|
||||||
|
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||||
|
// Fields needed to satisfy the server's Config type but unused by BooCoder:
|
||||||
|
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
|
||||||
|
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||||
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
|
GITEA_TOKEN: z.string().optional(),
|
||||||
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
|
// SSH access to the host for external agent dispatch (Phase 5)
|
||||||
|
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
|
||||||
|
BOOCODER_SSH_USER: z.string().default('samkintop'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
if (cached) return cached;
|
||||||
|
const parsed = ConfigSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment configuration:');
|
||||||
|
console.error(parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
cached = parsed.data;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
45
apps/coder/src/db.ts
Normal file
45
apps/coder/src/db.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
export type Sql = ReturnType<typeof postgres>;
|
||||||
|
|
||||||
|
let sqlInstance: Sql | null = null;
|
||||||
|
|
||||||
|
export function getSql(config: Config): Sql {
|
||||||
|
if (sqlInstance) return sqlInstance;
|
||||||
|
sqlInstance = postgres(config.DATABASE_URL, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
onnotice: () => {},
|
||||||
|
});
|
||||||
|
return sqlInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySchema(sql: Sql): Promise<void> {
|
||||||
|
const schemaPath = resolve(__dirname, 'schema.sql');
|
||||||
|
const ddl = await readFile(schemaPath, 'utf8');
|
||||||
|
await sql.unsafe(ddl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingDb(sql: Sql): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await sql`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (sqlInstance) {
|
||||||
|
await sqlInstance.end({ timeout: 5 });
|
||||||
|
sqlInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
183
apps/coder/src/index.ts
Normal file
183
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||||
|
import { startMcpServer } from './services/mcp-server.js';
|
||||||
|
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||||
|
// inference loop, broker, and tool registry without duplication.
|
||||||
|
import { createInferenceRunner } from '@boocode/server/inference';
|
||||||
|
import { createBroker } from '@boocode/server/broker';
|
||||||
|
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||||
|
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||||
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
|
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||||
|
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||||
|
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||||
|
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||||
|
// Routes
|
||||||
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerPendingRoutes } from './routes/pending.js';
|
||||||
|
import { registerTaskRoutes } from './routes/tasks.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';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// MCP mode: stdio transport, no HTTP server
|
||||||
|
if (process.argv.includes('--mcp')) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const sql = getSql(config);
|
||||||
|
await applySchema(sql);
|
||||||
|
await startMcpServer(sql);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.LOG_LEVEL },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow empty JSON bodies (same pattern as apps/server).
|
||||||
|
app.removeContentTypeParser(['application/json']);
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||||
|
const str = (body as string) ?? '';
|
||||||
|
if (str.trim().length === 0) {
|
||||||
|
done(null, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(str));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = getSql(config);
|
||||||
|
await applySchema(sql);
|
||||||
|
app.log.info('database schema applied');
|
||||||
|
|
||||||
|
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||||
|
const broker = createBroker(app.log);
|
||||||
|
|
||||||
|
// --- Tool registry extension ---
|
||||||
|
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||||
|
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||||
|
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
||||||
|
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
||||||
|
appendMcpTools(adaptedWriteTools);
|
||||||
|
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
||||||
|
|
||||||
|
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
||||||
|
// the appended write tools) for tool dispatch.
|
||||||
|
const inference = createInferenceRunner(
|
||||||
|
{
|
||||||
|
sql,
|
||||||
|
config: config as unknown as ServerConfig,
|
||||||
|
log: app.log,
|
||||||
|
publish: (sessionId, frame) => {
|
||||||
|
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
},
|
||||||
|
(user, frame) => {
|
||||||
|
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||||
|
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||||
|
// into the enqueue to set context before the run starts.
|
||||||
|
const inferenceApi = {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||||
|
// Set the inference context so write tools can access sql + sessionId.
|
||||||
|
// The context persists for the duration of the inference run. Since
|
||||||
|
// BooCoder is single-user and runs one inference at a time per session,
|
||||||
|
// this module-level state is safe.
|
||||||
|
setInferenceContext({ sql, sessionId, taskId: null });
|
||||||
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
|
},
|
||||||
|
cancel: async (sessionId: string, chatId: string) => {
|
||||||
|
const result = await inference.cancel(sessionId, chatId);
|
||||||
|
clearInferenceContext();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register WebSocket support
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
|
// Health endpoint
|
||||||
|
app.get('/api/health', async (_req, reply) => {
|
||||||
|
const dbOk = await pingDb(sql);
|
||||||
|
const status = dbOk ? 200 : 503;
|
||||||
|
return reply.status(status).send({
|
||||||
|
ok: dbOk,
|
||||||
|
db: dbOk,
|
||||||
|
tools: ALL_TOOLS.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 4: probe available agents on startup
|
||||||
|
await probeAgents(sql, app.log);
|
||||||
|
|
||||||
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||||
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||||
|
dispatcher.start();
|
||||||
|
app.addHook('onClose', () => dispatcher.stop());
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
registerPendingRoutes(app, sql);
|
||||||
|
registerTaskRoutes(app, sql, inferenceApi);
|
||||||
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
|
// Serve static frontend (built web app). In production, the dist/ is
|
||||||
|
// copied to ../web relative to the dist/ directory at /app/web. In dev,
|
||||||
|
// check adjacent to the source.
|
||||||
|
const webRoot = resolve(__dirname, '../web');
|
||||||
|
if (existsSync(webRoot)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: webRoot,
|
||||||
|
prefix: '/',
|
||||||
|
// Don't intercept /api routes — static only serves files that exist.
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
// SPA fallback: serve index.html for non-API routes that don't match a file.
|
||||||
|
app.setNotFoundHandler(async (req, reply) => {
|
||||||
|
if (req.url.startsWith('/api')) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'not found' };
|
||||||
|
}
|
||||||
|
return reply.sendFile('index.html');
|
||||||
|
});
|
||||||
|
app.log.info(`serving frontend from ${webRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
app.log.info('shutting down');
|
||||||
|
await app.close();
|
||||||
|
await closeDb();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
126
apps/coder/src/routes/messages.ts
Normal file
126
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const SendBody = z.object({
|
||||||
|
content: z.string().min(1).max(64_000),
|
||||||
|
chat_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface InferenceApi {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActive: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMessageRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
inference: InferenceApi,
|
||||||
|
): void {
|
||||||
|
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/messages',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = SendBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const { content, chat_id: chatId } = parsed.data;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found or not open in this session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if inference is already running on this chat
|
||||||
|
if (inference.hasActive(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'inference already running on this chat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish user message frames so WS subscribers see it immediately
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
content,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.user_message_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');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/stop',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Find active chats in this session
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
|
||||||
|
`;
|
||||||
|
let cancelled = false;
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (inference.hasActive(chat.id)) {
|
||||||
|
cancelled = await inference.cancel(sessionId, chat.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancelled };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/coder/src/routes/pending.ts
Normal file
121
apps/coder/src/routes/pending.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import {
|
||||||
|
listPending,
|
||||||
|
applyOne,
|
||||||
|
applyAll,
|
||||||
|
rejectOne,
|
||||||
|
rewindOne,
|
||||||
|
} from '../services/pending_changes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a session's project path.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM sessions s
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = ${sessionId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root from a pending change's session.
|
||||||
|
*/
|
||||||
|
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
|
||||||
|
const rows = await sql<{ path: string }[]>`
|
||||||
|
SELECT p.path FROM pending_changes pc
|
||||||
|
JOIN sessions s ON pc.session_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE pc.id = ${changeId}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? rows[0]!.path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/sessions/:sessionId/pending — list pending changes for a session
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await listPending(sql, sessionId);
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await applyAll(sql, sessionId, projectRoot);
|
||||||
|
return { results };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/apply — apply a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/apply',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await applyOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/reject — reject a single pending change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/reject',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
await rejectOne(sql, changeId);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/pending/:id/rewind — rewind (undo) an applied change
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/pending/:id/rewind',
|
||||||
|
async (req, reply) => {
|
||||||
|
const changeId = req.params.id;
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'pending change or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await rewindOne(sql, changeId, projectRoot);
|
||||||
|
if (!result.success) {
|
||||||
|
reply.code(422);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
138
apps/coder/src/routes/tasks.ts
Normal file
138
apps/coder/src/routes/tasks.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
interface InferenceApi {
|
||||||
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateBody = z.object({
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
input: z.string().min(1).max(64_000),
|
||||||
|
agent: z.string().max(100).optional(),
|
||||||
|
model: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ListQuery = z.object({
|
||||||
|
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
|
||||||
|
project_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
|
||||||
|
// POST /api/tasks — create a new task
|
||||||
|
app.post('/api/tasks', async (req, reply) => {
|
||||||
|
const parsed = CreateBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project_id, input, agent, model } = 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})
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return { id: task!.id, state: task!.state };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tasks — list tasks with optional filters
|
||||||
|
app.get('/api/tasks', async (req, _reply) => {
|
||||||
|
const parsed = ListQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state, project_id } = parsed.data;
|
||||||
|
|
||||||
|
// Build query with optional filters
|
||||||
|
if (state && project_id) {
|
||||||
|
return sql`
|
||||||
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE state = ${state} AND project_id = ${project_id}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
} else if (state) {
|
||||||
|
return sql`
|
||||||
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE state = ${state}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
} else if (project_id) {
|
||||||
|
return sql`
|
||||||
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE project_id = ${project_id}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return sql`
|
||||||
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||||
|
FROM tasks
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tasks/:id — single task detail
|
||||||
|
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'task not found' };
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tasks/:id/cancel — cancel a pending or running task
|
||||||
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
|
||||||
|
const taskId = req.params.id;
|
||||||
|
|
||||||
|
// Get current task state + session info
|
||||||
|
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
|
||||||
|
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'task not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = rows[0]!;
|
||||||
|
if (task.state !== 'pending' && task.state !== 'running') {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: `cannot cancel task in state '${task.state}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If running, try to cancel inference
|
||||||
|
if (task.state === 'running' && task.session_id) {
|
||||||
|
// Find active chat in the task's session
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
for (const chat of chats) {
|
||||||
|
await inference.cancel(task.session_id, chat.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||||
|
WHERE id = ${taskId} AND state IN ('pending', 'running')
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { cancelled: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
51
apps/coder/src/routes/ws.ts
Normal file
51
apps/coder/src/routes/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
|
||||||
|
export function registerWebSocket(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
broker: Broker,
|
||||||
|
): void {
|
||||||
|
// Per-session streaming WebSocket. Clients connect here to receive live
|
||||||
|
// inference frames (deltas, tool_calls, tool_results, message_complete).
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/ws/sessions/:sessionId',
|
||||||
|
{ websocket: true },
|
||||||
|
async (socket, req) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||||
|
if (session.length === 0) {
|
||||||
|
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
||||||
|
socket.close(1008, 'session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
|
summary, tail_start_id, compacted_at
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE session_id = ${sessionId}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||||
|
|
||||||
|
// Subscribe to broker for live frames
|
||||||
|
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||||
|
if (socket.readyState !== socket.OPEN) return;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err, sessionId }, 'ws send failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => unsubscribe());
|
||||||
|
socket.on('error', () => unsubscribe());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/coder/src/schema.sql
Normal file
51
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
|
||||||
|
-- Applied on startup by apps/coder/src/db.ts:applySchema().
|
||||||
|
-- Lives in the same 'boochat' database as BooChat's tables.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL,
|
||||||
|
task_id UUID,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
|
||||||
|
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
execution_path TEXT,
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||||
|
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS available_agents (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
install_path TEXT,
|
||||||
|
version TEXT,
|
||||||
|
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_probed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v2.0.0 Phase 4: link tasks to their inference sessions.
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
|
||||||
|
|
||||||
|
-- Human inbox: tasks needing attention
|
||||||
|
CREATE OR REPLACE VIEW human_inbox AS
|
||||||
|
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||||
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
|
||||||
|
|
||||||
|
const PROJECT_ROOT = '/opt/projects/my-app';
|
||||||
|
|
||||||
|
describe('resolveWritePath', () => {
|
||||||
|
it('resolves a relative path correctly', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/index.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves nested relative path', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on ../ escape', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on absolute path outside project root', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows absolute path inside project root', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.local', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies .env.production', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.pem files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies *.key files', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_rsa', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies id_ed25519', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies credentials.json', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a normal file inside project', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a non-existent nested file (no realpath)', () => {
|
||||||
|
// This is the key difference from BooChat's pathGuard: no realpath means
|
||||||
|
// files that don't exist yet still pass validation
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on null/empty path', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ../ within project root and still allows', () => {
|
||||||
|
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
|
||||||
|
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path that looks inside root but normalizes outside', () => {
|
||||||
|
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSecretPath', () => {
|
||||||
|
it('detects .env', () => {
|
||||||
|
expect(isSecretPath('.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects nested .env', () => {
|
||||||
|
expect(isSecretPath('config/.env')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects *.pfx', () => {
|
||||||
|
expect(isSecretPath('certs/client.pfx')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag normal source files', () => {
|
||||||
|
expect(isSecretPath('src/index.ts')).toBe(false);
|
||||||
|
expect(isSecretPath('README.md')).toBe(false);
|
||||||
|
expect(isSecretPath('package.json')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isSecretPath('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
271
apps/coder/src/services/acp-dispatch.ts
Normal file
271
apps/coder/src/services/acp-dispatch.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { Readable, Writable } from 'node:stream';
|
||||||
|
import {
|
||||||
|
ClientSideConnection,
|
||||||
|
ndJsonStream,
|
||||||
|
type Client,
|
||||||
|
type SessionNotification,
|
||||||
|
type RequestPermissionRequest,
|
||||||
|
type RequestPermissionResponse,
|
||||||
|
type ReadTextFileRequest,
|
||||||
|
type ReadTextFileResponse,
|
||||||
|
type WriteTextFileRequest,
|
||||||
|
type WriteTextFileResponse,
|
||||||
|
type CreateTerminalRequest,
|
||||||
|
type CreateTerminalResponse,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
|
import { sshSpawn } from './ssh.js';
|
||||||
|
|
||||||
|
export interface AcpDispatchResult {
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>;
|
||||||
|
stopReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcpDispatchOpts {
|
||||||
|
agent: string;
|
||||||
|
task: string;
|
||||||
|
worktreePath: string;
|
||||||
|
model?: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 cmd = acpCommand(agent);
|
||||||
|
if (!cmd) {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: `Agent '${agent}' does not support ACP.`,
|
||||||
|
toolCalls: [],
|
||||||
|
stopReason: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn SSH with the ACP command running in the worktree
|
||||||
|
const escapedPath = worktreePath.replace(/'/g, "'\\''");
|
||||||
|
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
|
||||||
|
|
||||||
|
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
|
||||||
|
const child = sshSpawn(fullCommand);
|
||||||
|
|
||||||
|
// Wire up abort
|
||||||
|
let killed = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (!killed) {
|
||||||
|
killed = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
cleanup();
|
||||||
|
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], 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 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' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize the connection
|
||||||
|
// ProtocolVersion is a number in this SDK version
|
||||||
|
const initResult = await connection.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientInfo: { name: 'boocoder', version: '2.0.1' },
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Send the prompt
|
||||||
|
const promptResult = await connection.prompt({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
prompt: [{ type: 'text', text: task }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopReason = promptResult.stopReason ?? 'end_turn';
|
||||||
|
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed');
|
||||||
|
|
||||||
|
// Clean shutdown
|
||||||
|
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
output: textChunks.join(''),
|
||||||
|
toolCalls,
|
||||||
|
stopReason,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ agent, err: message }, 'acp-dispatch: error');
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: message,
|
||||||
|
toolCalls: [],
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/coder/src/services/agent-probe.ts
Normal file
70
apps/coder/src/services/agent-probe.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { sshExec } from './ssh.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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||||
|
log.info('agent-probe: scanning HOST for known agents via SSH');
|
||||||
|
|
||||||
|
for (const agent of KNOWN_AGENTS) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some agents may not support --version — that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ACP-capable agents, verify ACP mode actually works
|
||||||
|
let supportsAcp = agent.supportsAcp;
|
||||||
|
if (supportsAcp) {
|
||||||
|
try {
|
||||||
|
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
|
||||||
|
supportsAcp = acpCheck.exitCode === 0;
|
||||||
|
} catch {
|
||||||
|
supportsAcp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT into available_agents
|
||||||
|
await sql`
|
||||||
|
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
|
||||||
|
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host');
|
||||||
|
} 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.info('agent-probe: scan complete');
|
||||||
|
}
|
||||||
368
apps/coder/src/services/dispatcher.ts
Normal file
368
apps/coder/src/services/dispatcher.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { Broker } from '@boocode/server/broker';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface InferenceRunner {
|
||||||
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActive: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
sql: Sql;
|
||||||
|
inference: InferenceRunner;
|
||||||
|
broker: Broker;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
const COMPLETION_POLL_MS = 2_000;
|
||||||
|
|
||||||
|
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||||
|
const { sql, inference, log, config } = deps;
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let running = false;
|
||||||
|
let stopping = false;
|
||||||
|
let inflightPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (running || stopping) return;
|
||||||
|
|
||||||
|
// Grab one pending task
|
||||||
|
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
|
||||||
|
SELECT id, project_id, input, agent, model
|
||||||
|
FROM tasks
|
||||||
|
WHERE state = 'pending'
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const task = rows[0]!;
|
||||||
|
running = true;
|
||||||
|
inflightPromise = runTask(task).finally(() => {
|
||||||
|
running = false;
|
||||||
|
inflightPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): 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}
|
||||||
|
`;
|
||||||
|
if (agentRow) {
|
||||||
|
await runExternalAgent(task, agentRow.supports_acp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Agent specified but not available — fall through to Path A with a warning
|
||||||
|
log.warn({ taskId, agent: task.agent }, 'dispatcher: specified agent not available, falling back to native');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path A — native inference (existing behavior)
|
||||||
|
await runNativeInference(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
|
||||||
|
const taskId = task.id;
|
||||||
|
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mark running
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'running', started_at = clock_timestamp(), execution_path = 'native'
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create session + chat for this task
|
||||||
|
const model = task.model ?? config.DEFAULT_MODEL;
|
||||||
|
const sessionName = 'Task: ' + task.input.slice(0, 40);
|
||||||
|
|
||||||
|
const [session] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO sessions (project_id, name, model, status)
|
||||||
|
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const sessionId = session!.id;
|
||||||
|
|
||||||
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, 'Task execution', 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const chatId = chat!.id;
|
||||||
|
|
||||||
|
// Link task to session
|
||||||
|
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||||
|
|
||||||
|
// Create user message + streaming assistant
|
||||||
|
await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
// Enqueue inference
|
||||||
|
inference.enqueue(sessionId, chatId, assistantId, 'default');
|
||||||
|
|
||||||
|
// Wait for inference to complete (poll message status)
|
||||||
|
const finalStatus = await waitForCompletion(assistantId);
|
||||||
|
|
||||||
|
if (stopping) {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus === 'complete') {
|
||||||
|
const [msg] = await sql<{ content: string | null }[]>`
|
||||||
|
SELECT content FROM messages WHERE id = ${assistantId}
|
||||||
|
`;
|
||||||
|
const summary = (msg?.content ?? '').slice(0, 500);
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
log.info({ taskId }, 'dispatcher: task completed (native)');
|
||||||
|
} else {
|
||||||
|
const [msg] = await sql<{ content: string | null }[]>`
|
||||||
|
SELECT content FROM messages WHERE id = ${assistantId}
|
||||||
|
`;
|
||||||
|
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||||||
|
|
||||||
|
async function runExternalAgent(
|
||||||
|
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null },
|
||||||
|
supportsAcp: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const taskId = task.id;
|
||||||
|
const agent = task.agent!;
|
||||||
|
const executionPath = supportsAcp ? 'acp' : 'pty';
|
||||||
|
|
||||||
|
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
|
||||||
|
|
||||||
|
// Resolve the project's root path
|
||||||
|
const [project] = await sql<{ root_path: string | null }[]>`
|
||||||
|
SELECT root_path FROM projects WHERE id = ${task.project_id}
|
||||||
|
`;
|
||||||
|
const projectPath = project?.root_path;
|
||||||
|
if (!projectPath) {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_path — cannot create worktree'
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an abort controller for this task
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mark running
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'running', started_at = clock_timestamp(), execution_path = ${executionPath}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Link task to session
|
||||||
|
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Step 1: Create worktree
|
||||||
|
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
|
||||||
|
const worktreePath = await createWorktree(projectPath, taskId, { signal: ac.signal });
|
||||||
|
log.info({ taskId, worktreePath }, 'dispatcher: worktree created');
|
||||||
|
|
||||||
|
// Step 2: Dispatch to agent
|
||||||
|
let outputSummary: string;
|
||||||
|
|
||||||
|
if (supportsAcp) {
|
||||||
|
const result = await dispatchViaAcp({
|
||||||
|
agent,
|
||||||
|
task: task.input,
|
||||||
|
worktreePath,
|
||||||
|
model: task.model ?? undefined,
|
||||||
|
signal: ac.signal,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
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())
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const result = await dispatchViaPty({
|
||||||
|
agent,
|
||||||
|
task: task.input,
|
||||||
|
worktreePath,
|
||||||
|
model: task.model ?? undefined,
|
||||||
|
signal: ac.signal,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
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 (stopping) {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
await cleanupWorktree(projectPath, taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Diff the worktree and queue pending changes
|
||||||
|
log.info({ taskId }, 'dispatcher: diffing worktree');
|
||||||
|
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
|
||||||
|
|
||||||
|
if (diff) {
|
||||||
|
// Queue a single pending_change entry with the full unified diff
|
||||||
|
await sql`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff})
|
||||||
|
`;
|
||||||
|
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
|
||||||
|
} else {
|
||||||
|
log.info({ taskId }, 'dispatcher: no changes detected in worktree');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Cleanup worktree
|
||||||
|
await cleanupWorktree(projectPath, taskId);
|
||||||
|
|
||||||
|
// Step 5: Mark task completed
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${outputSummary}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
log.info({ taskId, agent }, 'dispatcher: task completed (external)');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ taskId, agent, err: errMsg }, 'dispatcher: external agent error');
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`.catch(() => {});
|
||||||
|
|
||||||
|
// Best-effort cleanup
|
||||||
|
await cleanupWorktree(projectPath, taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function waitForCompletion(assistantId: string): Promise<string> {
|
||||||
|
for (;;) {
|
||||||
|
if (stopping) return 'cancelled';
|
||||||
|
|
||||||
|
const [row] = await sql<{ status: string }[]>`
|
||||||
|
SELECT status FROM messages WHERE id = ${assistantId}
|
||||||
|
`;
|
||||||
|
const status = row?.status ?? 'failed';
|
||||||
|
if (status !== 'streaming') return status;
|
||||||
|
|
||||||
|
await sleep(COMPLETION_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
log.info('dispatcher: starting poll loop');
|
||||||
|
timer = setInterval(() => {
|
||||||
|
poll().catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: poll error');
|
||||||
|
});
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
stopping = true;
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (inflightPromise) {
|
||||||
|
log.info('dispatcher: waiting for in-flight task');
|
||||||
|
await inflightPromise;
|
||||||
|
}
|
||||||
|
log.info('dispatcher: stopped');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
201
apps/coder/src/services/mcp-server.ts
Normal file
201
apps/coder/src/services/mcp-server.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* BooCoder MCP Server — exposes task primitives as MCP tools.
|
||||||
|
*
|
||||||
|
* Started when `--mcp` flag is passed to the entry point. Runs stdio transport
|
||||||
|
* so external tools (opencode in Termius) can drive the task queue.
|
||||||
|
*/
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import { applyOne, rejectOne } from './pending_changes.js';
|
||||||
|
|
||||||
|
// --- Tool handlers -----------------------------------------------------------
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRow {
|
||||||
|
id: string;
|
||||||
|
file_path: string;
|
||||||
|
operation: string;
|
||||||
|
diff: string;
|
||||||
|
session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorktreeRow {
|
||||||
|
id: string;
|
||||||
|
worktree_path: string;
|
||||||
|
agent: string;
|
||||||
|
started_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectPathRow {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(data: unknown) {
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public entry ------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function startMcpServer(sql: Sql): Promise<void> {
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: 'boocoder', version: '2.0.2' },
|
||||||
|
{ capabilities: { tools: {} } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. boocoder.create_task
|
||||||
|
server.tool(
|
||||||
|
'boocoder.create_task',
|
||||||
|
'Create a new task in the BooCoder task queue',
|
||||||
|
{
|
||||||
|
project_id: z.string().describe('Project UUID'),
|
||||||
|
input: z.string().describe('Task description / prompt for the agent'),
|
||||||
|
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
|
||||||
|
model: z.string().optional().describe('Model override (optional)'),
|
||||||
|
},
|
||||||
|
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')
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
return textResult({ task_id: row!.id, state: row!.state });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. boocoder.list_pending_changes
|
||||||
|
server.tool(
|
||||||
|
'boocoder.list_pending_changes',
|
||||||
|
'List pending changes awaiting review',
|
||||||
|
{
|
||||||
|
session_id: z.string().optional().describe('Optional session filter'),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
let rows: PendingRow[];
|
||||||
|
if (args.session_id) {
|
||||||
|
rows = await sql<PendingRow[]>`
|
||||||
|
SELECT id, file_path, operation, diff, session_id
|
||||||
|
FROM pending_changes
|
||||||
|
WHERE status = 'pending' AND session_id = ${args.session_id}
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
rows = await sql<PendingRow[]>`
|
||||||
|
SELECT id, file_path, operation, diff, session_id
|
||||||
|
FROM pending_changes
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const items = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
file_path: r.file_path,
|
||||||
|
operation: r.operation,
|
||||||
|
diff_preview: r.diff.slice(0, 200),
|
||||||
|
}));
|
||||||
|
return textResult(items);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. boocoder.apply
|
||||||
|
server.tool(
|
||||||
|
'boocoder.apply',
|
||||||
|
'Apply a pending change (write to disk)',
|
||||||
|
{
|
||||||
|
change_id: z.string().describe('Pending change UUID'),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
// Resolve projectRoot from the change's session → project path
|
||||||
|
const [proj] = await sql<ProjectPathRow[]>`
|
||||||
|
SELECT p.path FROM pending_changes pc
|
||||||
|
JOIN sessions s ON pc.session_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE pc.id = ${args.change_id}
|
||||||
|
`;
|
||||||
|
if (!proj) {
|
||||||
|
return textResult({ success: false, file_path: '', error: 'change not found or project path unresolved' });
|
||||||
|
}
|
||||||
|
const result = await applyOne(sql, args.change_id, proj.path);
|
||||||
|
return textResult({ success: result.success, file_path: result.file_path, error: result.error });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. boocoder.reject
|
||||||
|
server.tool(
|
||||||
|
'boocoder.reject',
|
||||||
|
'Reject a pending change (mark as rejected, no disk write)',
|
||||||
|
{
|
||||||
|
change_id: z.string().describe('Pending change UUID'),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
await rejectOne(sql, args.change_id);
|
||||||
|
return textResult({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. boocoder.dispatch_external_agent
|
||||||
|
server.tool(
|
||||||
|
'boocoder.dispatch_external_agent',
|
||||||
|
'Create a task targeting a specific external agent (ACP or PTY dispatch)',
|
||||||
|
{
|
||||||
|
project_id: z.string().describe('Project UUID'),
|
||||||
|
input: z.string().describe('Task prompt'),
|
||||||
|
agent: z.string().describe('Agent name (must match available_agents registry)'),
|
||||||
|
model: z.string().optional().describe('Model override (optional)'),
|
||||||
|
},
|
||||||
|
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')
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Determine execution path from available_agents
|
||||||
|
const [agentRow] = await sql<{ supports_acp: boolean }[]>`
|
||||||
|
SELECT supports_acp FROM available_agents WHERE name = ${args.agent}
|
||||||
|
`;
|
||||||
|
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
|
||||||
|
|
||||||
|
return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. boocoder.list_worktrees
|
||||||
|
server.tool(
|
||||||
|
'boocoder.list_worktrees',
|
||||||
|
'List active worktrees from running tasks',
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
const rows = await sql<WorktreeRow[]>`
|
||||||
|
SELECT id, worktree_path, agent, started_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE worktree_path IS NOT NULL AND state = 'running'
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
`;
|
||||||
|
const items = rows.map((r) => ({
|
||||||
|
task_id: r.id,
|
||||||
|
worktree_path: r.worktree_path,
|
||||||
|
agent: r.agent,
|
||||||
|
started_at: r.started_at,
|
||||||
|
}));
|
||||||
|
return textResult(items);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connect via stdio
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
// Block until stdin closes (transport handles lifecycle)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.stdin.on('end', resolve);
|
||||||
|
process.stdin.on('close', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
}
|
||||||
224
apps/coder/src/services/pending_changes.ts
Normal file
224
apps/coder/src/services/pending_changes.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import { resolveWritePath } from './write_guard.js';
|
||||||
|
|
||||||
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'edit' | 'delete';
|
||||||
|
diff: string;
|
||||||
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
id: string;
|
||||||
|
file_path: string;
|
||||||
|
operation: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queue functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function queueEdit(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
oldString: string,
|
||||||
|
newString: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueCreate(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueDelete(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string | null,
|
||||||
|
filePath: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<PendingChange> {
|
||||||
|
const resolved = resolveWritePath(projectRoot, filePath);
|
||||||
|
|
||||||
|
const [row] = await sql<PendingChange[]>`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Apply functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function applyOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-validate path in case projectRoot has shifted
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(oldStr)) {
|
||||||
|
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
||||||
|
}
|
||||||
|
const updated = content.replace(oldStr, newStr);
|
||||||
|
await writeFile(change.file_path, updated, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Stash current content in diff for potential rewind
|
||||||
|
try {
|
||||||
|
const existing = await readFile(change.file_path, 'utf8');
|
||||||
|
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||||
|
} catch {
|
||||||
|
// File may already be gone — proceed with status update
|
||||||
|
}
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAll(
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult[]> {
|
||||||
|
const pending = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
const results: ApplyResult[] = [];
|
||||||
|
for (const change of pending) {
|
||||||
|
results.push(await applyOne(sql, change.id, projectRoot));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reject functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
||||||
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rewind functions --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function rewindOne(
|
||||||
|
sql: Sql,
|
||||||
|
changeId: string,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
const [change] = await sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
||||||
|
`;
|
||||||
|
if (!change) {
|
||||||
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveWritePath(projectRoot, change.file_path);
|
||||||
|
|
||||||
|
switch (change.operation) {
|
||||||
|
case 'create': {
|
||||||
|
// Reverse a create: delete the file
|
||||||
|
await unlink(change.file_path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit': {
|
||||||
|
// Reverse an edit: swap old and new
|
||||||
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
|
if (!content.includes(newStr)) {
|
||||||
|
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
||||||
|
}
|
||||||
|
const reverted = content.replace(newStr, oldStr);
|
||||||
|
await writeFile(change.file_path, reverted, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||||
|
await mkdir(dirname(change.file_path), { recursive: true });
|
||||||
|
await writeFile(change.file_path, change.diff, 'utf8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Query functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
||||||
|
return sql<PendingChange[]>`
|
||||||
|
SELECT * FROM pending_changes
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
139
apps/coder/src/services/pty-dispatch.ts
Normal file
139
apps/coder/src/services/pty-dispatch.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
* - goose: stub (not yet supported)
|
||||||
|
* - pi: stub (not yet supported)
|
||||||
|
*/
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { sshSpawnWithStdin } from './ssh.js';
|
||||||
|
|
||||||
|
export interface DispatchResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PtyDispatchOpts {
|
||||||
|
agent: string;
|
||||||
|
task: string;
|
||||||
|
worktreePath: string;
|
||||||
|
model?: 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, "'\\''");
|
||||||
|
|
||||||
|
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 '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 'goose':
|
||||||
|
// Not yet verified for non-interactive use
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'pi':
|
||||||
|
// Not yet verified for non-interactive use
|
||||||
|
return null;
|
||||||
|
|
||||||
|
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 agentCmd = buildAgentCommand(agent, task, model);
|
||||||
|
if (!agentCmd) {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap in cd to the worktree
|
||||||
|
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
|
||||||
|
|
||||||
|
log.info({ agent, worktreePath }, '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.
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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');
|
||||||
|
// Give it a moment then force-kill
|
||||||
|
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
cleanup();
|
||||||
|
resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', cleanup, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (signal) signal.removeEventListener('abort', cleanup);
|
||||||
|
log.info({ agent, exitCode: code }, 'pty-dispatch: completed');
|
||||||
|
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (signal) signal.removeEventListener('abort', cleanup);
|
||||||
|
log.error({ agent, err: err.message }, 'pty-dispatch: spawn error');
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
126
apps/coder/src/services/ssh.ts
Normal file
126
apps/coder/src/services/ssh.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
30
apps/coder/src/services/tools/adapter.ts
Normal file
30
apps/coder/src/services/tools/adapter.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
|
||||||
|
* interface (which takes `projectRoot, extraRoots?`).
|
||||||
|
*
|
||||||
|
* The adapter reads the module-level inference context at execute time, so the
|
||||||
|
* wrapping happens at boot (static) — no per-inference re-wrap needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { getInferenceContext } from './inference_context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
|
||||||
|
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
|
||||||
|
* builds the ToolContext from the module-level inference context at call time.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
jsonSchema: tool.jsonSchema,
|
||||||
|
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
|
||||||
|
const ctx: ToolContext = getInferenceContext();
|
||||||
|
return tool.execute(input, projectRoot, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { applyAll } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const ApplyPendingInput = z.object({});
|
||||||
|
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
||||||
|
|
||||||
|
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
inputSchema: ApplyPendingInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'apply_pending',
|
||||||
|
description:
|
||||||
|
'Apply all pending changes for the current session to disk. ' +
|
||||||
|
'Each queued create/edit/delete is executed in order.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No pending changes to apply.'
|
||||||
|
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
51
apps/coder/src/services/tools/create_file.ts
Normal file
51
apps/coder/src/services/tools/create_file.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueCreate } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const CreateFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
type CreateFileInputT = z.infer<typeof CreateFileInput>;
|
||||||
|
|
||||||
|
export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: CreateFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_file',
|
||||||
|
description:
|
||||||
|
'Queue creation of a new file with the given content. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
|
||||||
|
content: { type: 'string', description: 'Full content of the file to create' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueCreate(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.content,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'create',
|
||||||
|
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/delete_file.ts
Normal file
48
apps/coder/src/services/tools/delete_file.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueDelete } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const DeleteFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
});
|
||||||
|
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
|
||||||
|
|
||||||
|
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: DeleteFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'delete_file',
|
||||||
|
description:
|
||||||
|
'Queue deletion of a file. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueDelete(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'delete',
|
||||||
|
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
54
apps/coder/src/services/tools/edit_file.ts
Normal file
54
apps/coder/src/services/tools/edit_file.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { queueEdit } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const EditFileInput = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
old_string: z.string().min(1),
|
||||||
|
new_string: z.string(),
|
||||||
|
});
|
||||||
|
type EditFileInputT = z.infer<typeof EditFileInput>;
|
||||||
|
|
||||||
|
export const editFileTool: ToolDef<EditFileInputT> = {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
inputSchema: EditFileInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'edit_file',
|
||||||
|
description:
|
||||||
|
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||||
|
'The change is staged in pending_changes and must be applied explicitly.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
|
||||||
|
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
|
||||||
|
new_string: { type: 'string', description: 'The replacement string' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'old_string', 'new_string'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
const change = await queueEdit(
|
||||||
|
context.sql,
|
||||||
|
context.sessionId,
|
||||||
|
context.taskId,
|
||||||
|
input.file_path,
|
||||||
|
input.old_string,
|
||||||
|
input.new_string,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'queued',
|
||||||
|
change_id: change.id,
|
||||||
|
file_path: change.file_path,
|
||||||
|
operation: 'edit',
|
||||||
|
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
26
apps/coder/src/services/tools/index.ts
Normal file
26
apps/coder/src/services/tools/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ToolDef } from './types.js';
|
||||||
|
import { editFileTool } from './edit_file.js';
|
||||||
|
import { createFileTool } from './create_file.js';
|
||||||
|
import { deleteFileTool } from './delete_file.js';
|
||||||
|
import { applyPendingTool } from './apply_pending.js';
|
||||||
|
import { rewindTool } from './rewind.js';
|
||||||
|
|
||||||
|
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||||
|
|
||||||
|
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
|
||||||
|
// with BooChat's read-only tools to form the full tool set available to agents.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||||
|
applyPendingTool,
|
||||||
|
createFileTool,
|
||||||
|
deleteFileTool,
|
||||||
|
editFileTool,
|
||||||
|
rewindTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
|
||||||
|
WRITE_TOOLS.map((t) => [t.name, t]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };
|
||||||
36
apps/coder/src/services/tools/inference_context.ts
Normal file
36
apps/coder/src/services/tools/inference_context.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level inference context for write tools.
|
||||||
|
*
|
||||||
|
* Set via `setInferenceContext()` before each inference run starts.
|
||||||
|
* Write tools read it via `getInferenceContext()` during execute.
|
||||||
|
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||||
|
* ambient state that can't be threaded through the tool-phase execute
|
||||||
|
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InferenceContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: InferenceContext | null = null;
|
||||||
|
|
||||||
|
export function setInferenceContext(ctx: InferenceContext): void {
|
||||||
|
current = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearInferenceContext(): void {
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInferenceContext(): InferenceContext {
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
71
apps/coder/src/services/tools/rewind.ts
Normal file
71
apps/coder/src/services/tools/rewind.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { rewindOne } from '../pending_changes.js';
|
||||||
|
|
||||||
|
const RewindInput = z.object({
|
||||||
|
change_id: z.string().uuid().optional(),
|
||||||
|
all: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
type RewindInputT = z.infer<typeof RewindInput>;
|
||||||
|
|
||||||
|
export const rewindTool: ToolDef<RewindInputT> = {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
inputSchema: RewindInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rewind',
|
||||||
|
description:
|
||||||
|
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||||
|
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
|
||||||
|
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||||
|
if (input.change_id) {
|
||||||
|
const result = await rewindOne(context.sql, input.change_id, projectRoot);
|
||||||
|
return {
|
||||||
|
results: [result],
|
||||||
|
message: result.success
|
||||||
|
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
|
||||||
|
: `Failed to revert: ${result.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.all) {
|
||||||
|
// Rewind all applied changes for this session in reverse order
|
||||||
|
const applied = await context.sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM pending_changes
|
||||||
|
WHERE session_id = ${context.sessionId} AND status = 'applied'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const results = [];
|
||||||
|
for (const row of applied) {
|
||||||
|
results.push(await rewindOne(context.sql, row.id, projectRoot));
|
||||||
|
}
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
return {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed: results.length - succeeded,
|
||||||
|
results,
|
||||||
|
message:
|
||||||
|
results.length === 0
|
||||||
|
? 'No applied changes to revert.'
|
||||||
|
: `Reverted ${succeeded}/${results.length} changes.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'Provide either change_id or all=true.' };
|
||||||
|
},
|
||||||
|
};
|
||||||
32
apps/coder/src/services/tools/types.ts
Normal file
32
apps/coder/src/services/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
|
||||||
|
export interface ToolJsonSchema {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to BooCoder tool execute functions.
|
||||||
|
*
|
||||||
|
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
|
||||||
|
* interact with the database (pending_changes table) and need session/task
|
||||||
|
* context for proper attribution.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDef<TInput> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: z.ZodType<TInput>;
|
||||||
|
jsonSchema: ToolJsonSchema;
|
||||||
|
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
|
||||||
|
}
|
||||||
118
apps/coder/src/services/worktrees.ts
Normal file
118
apps/coder/src/services/worktrees.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Git worktree management for external agent dispatch.
|
||||||
|
*
|
||||||
|
* Each dispatched task gets its own git worktree so the external agent
|
||||||
|
* can modify files freely without touching the main working tree.
|
||||||
|
* After the agent completes, we diff the worktree against HEAD and
|
||||||
|
* queue the diff into pending_changes.
|
||||||
|
*/
|
||||||
|
import { sshExec } from './ssh.js';
|
||||||
|
|
||||||
|
const WORKTREE_BASE = '/tmp/booworktrees';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a git worktree for a task on the host.
|
||||||
|
* Returns the absolute path to the worktree directory.
|
||||||
|
*/
|
||||||
|
export async function createWorktree(
|
||||||
|
projectPath: string,
|
||||||
|
taskId: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<string> {
|
||||||
|
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
||||||
|
const branchName = `task-${taskId}`;
|
||||||
|
|
||||||
|
// Ensure the base directory exists
|
||||||
|
await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
||||||
|
|
||||||
|
// Create the worktree with a new branch from HEAD
|
||||||
|
const result = await sshExec(
|
||||||
|
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return worktreePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unified diff of changes made in the worktree vs the parent branch (HEAD).
|
||||||
|
* Returns an empty string if there are no changes.
|
||||||
|
*/
|
||||||
|
export async function diffWorktree(
|
||||||
|
worktreePath: string,
|
||||||
|
projectPath: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<string> {
|
||||||
|
// First, commit any uncommitted changes in the worktree so we can diff branches
|
||||||
|
// Stage all changes
|
||||||
|
const addResult = await sshExec(
|
||||||
|
`cd ${shellEscape(worktreePath)} && git add -A`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
if (addResult.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are staged changes
|
||||||
|
const statusResult = await sshExec(
|
||||||
|
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusResult.exitCode === 0) {
|
||||||
|
// No changes
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit staged changes (needed to produce a clean branch diff)
|
||||||
|
await sshExec(
|
||||||
|
`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(
|
||||||
|
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diffResult.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffResult.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a worktree and its associated branch.
|
||||||
|
* Best-effort — does not throw on failure (task may have already been cleaned up).
|
||||||
|
*/
|
||||||
|
export async function cleanupWorktree(
|
||||||
|
projectPath: string,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
||||||
|
const branchName = `task-${taskId}`;
|
||||||
|
|
||||||
|
// Remove the worktree (--force handles dirty state)
|
||||||
|
await sshExec(
|
||||||
|
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
|
||||||
|
{ timeoutMs: 15_000 },
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
// Delete the task branch
|
||||||
|
await sshExec(
|
||||||
|
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
|
||||||
|
{ timeoutMs: 10_000 },
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||||
|
function shellEscape(s: string): string {
|
||||||
|
// Replace single quotes with escaped version, wrap in single quotes
|
||||||
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
73
apps/coder/src/services/write_guard.ts
Normal file
73
apps/coder/src/services/write_guard.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
export class WriteGuardError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'WriteGuardError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny list: files that should never be written regardless of path-guard.
|
||||||
|
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
||||||
|
// Full parity with BooChat's deny list is not needed for write-guard because
|
||||||
|
// the write tools are intentional (model chose to create/edit); we block only
|
||||||
|
// files that are unambiguously secrets.
|
||||||
|
const SECRET_PATTERNS: readonly string[] = [
|
||||||
|
'.env',
|
||||||
|
'.env.local',
|
||||||
|
'.env.production',
|
||||||
|
'.env.development',
|
||||||
|
'.env.staging',
|
||||||
|
'id_rsa',
|
||||||
|
'id_dsa',
|
||||||
|
'id_ecdsa',
|
||||||
|
'id_ed25519',
|
||||||
|
'*.pem',
|
||||||
|
'*.key',
|
||||||
|
'*.p12',
|
||||||
|
'*.pfx',
|
||||||
|
'*.crt',
|
||||||
|
'credentials.json',
|
||||||
|
'*.kdbx',
|
||||||
|
'.netrc',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSecretPath(filePath: string): boolean {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) return false;
|
||||||
|
const basename = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
return SECRET_PATTERNS.some((pattern) => {
|
||||||
|
if (pattern.startsWith('*')) {
|
||||||
|
return basename.endsWith(pattern.slice(1));
|
||||||
|
}
|
||||||
|
return basename === pattern;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and validate a write target path.
|
||||||
|
*
|
||||||
|
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
||||||
|
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
||||||
|
* checks the result stays within projectRoot.
|
||||||
|
*/
|
||||||
|
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||||
|
if (!filePath || filePath.length === 0) {
|
||||||
|
throw new WriteGuardError('file path is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||||
|
const normalized = resolve(candidate); // normalizes ../ segments
|
||||||
|
|
||||||
|
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
||||||
|
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecretPath(normalized)) {
|
||||||
|
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
15
apps/coder/tsconfig.json
Normal file
15
apps/coder/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||||
|
}
|
||||||
9
apps/coder/vitest.config.ts
Normal file
9
apps/coder/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
12
apps/coder/web/index.html
Normal file
12
apps/coder/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BooCoder</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-900 text-zinc-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
apps/coder/web/package.json
Normal file
29
apps/coder/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/coder-web",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/coder/web/postcss.config.js
Normal file
5
apps/coder/web/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
apps/coder/web/src/App.tsx
Normal file
13
apps/coder/web/src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Home } from './pages/Home';
|
||||||
|
import { Session } from './pages/Session';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/sessions/:sessionId" element={<Session />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/coder/web/src/api/client.ts
Normal file
93
apps/coder/web/src/api/client.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Project, Session, Chat, Message, PendingChange } from './types';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: unknown,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
typeof body === 'object' && body && 'error' in body
|
||||||
|
? String((body as { error: unknown }).error)
|
||||||
|
: `HTTP ${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : undefined;
|
||||||
|
if (!res.ok) throw new ApiError(res.status, data);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'),
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
list: (params?: { status?: 'open' | 'archived' }) =>
|
||||||
|
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
sessions: {
|
||||||
|
listForProject: (projectId: string, status?: 'open' | 'archived') =>
|
||||||
|
request<Session[]>(
|
||||||
|
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
|
||||||
|
),
|
||||||
|
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
chats: {
|
||||||
|
listForSession: (sessionId: string) =>
|
||||||
|
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
|
||||||
|
create: (sessionId: string, body?: { name?: string }) =>
|
||||||
|
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
messages: {
|
||||||
|
send: (sessionId: string, chatId: string, content: string) =>
|
||||||
|
request<{ user_message_id: string; assistant_message_id: string }>(
|
||||||
|
`/api/sessions/${sessionId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content, chat_id: chatId }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
stop: (sessionId: string) =>
|
||||||
|
request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
pending: {
|
||||||
|
list: (sessionId: string) =>
|
||||||
|
request<PendingChange[]>(`/api/sessions/${sessionId}/pending`),
|
||||||
|
applyAll: (sessionId: string) =>
|
||||||
|
request<{ results: Array<{ id: string; success: boolean; error?: string }> }>(
|
||||||
|
`/api/sessions/${sessionId}/pending/apply`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
|
applyOne: (changeId: string) =>
|
||||||
|
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
rejectOne: (changeId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
rewindOne: (changeId: string) =>
|
||||||
|
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
89
apps/coder/web/src/api/types.ts
Normal file
89
apps/coder/web/src/api/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Minimal types for the BooCoder frontend.
|
||||||
|
// Shared DB entities (same schema as BooChat).
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
model: string | null;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
name: string | null;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
tool_call_id: string;
|
||||||
|
output: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
chat_id: string;
|
||||||
|
role: 'user' | 'assistant' | 'tool' | 'system';
|
||||||
|
content: string;
|
||||||
|
kind: string;
|
||||||
|
tool_calls: ToolCall[] | null;
|
||||||
|
tool_results: ToolResult | null;
|
||||||
|
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||||
|
tokens_used: number | null;
|
||||||
|
ctx_used: number | null;
|
||||||
|
ctx_max: number | null;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
metadata: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'edit' | 'delete';
|
||||||
|
old_string: string | null;
|
||||||
|
new_string: string | null;
|
||||||
|
content: string | null;
|
||||||
|
diff: string | null;
|
||||||
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||||
|
created_at: string;
|
||||||
|
applied_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket frame types (subset of what the coder backend publishes)
|
||||||
|
export type WsFrame =
|
||||||
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
|
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
|
||||||
|
| { type: 'delta'; message_id: string; chat_id: string; content: string }
|
||||||
|
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
|
||||||
|
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
|
||||||
|
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
|
||||||
|
| { type: 'error'; message_id?: string; error: string; reason?: string }
|
||||||
|
| { type: 'pending_change_added'; change: PendingChange }
|
||||||
|
| { type: 'pending_change_updated'; change: PendingChange };
|
||||||
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Square } from 'lucide-react';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { MessageBubble } from './MessageBubble';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
chatId: string;
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const content = input.trim();
|
||||||
|
if (!content || sending || isStreaming) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.messages.send(sessionId, chatId, content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('send failed:', err);
|
||||||
|
// Restore input on failure
|
||||||
|
setInput(content);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
try {
|
||||||
|
await api.messages.stop(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('stop failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out system messages for display (sentinels)
|
||||||
|
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
|
||||||
|
<div
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||||
|
/>
|
||||||
|
<span>{connected ? 'Connected' : 'Disconnected'}</span>
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="text-blue-400 ml-auto">Generating...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{visibleMessages.length === 0 && (
|
||||||
|
<div className="text-center text-zinc-500 mt-8">
|
||||||
|
<p className="text-lg font-medium">BooCoder</p>
|
||||||
|
<p className="text-sm mt-1">Send a message to start coding.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleMessages.map((msg) => (
|
||||||
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="border-t border-zinc-800 px-4 py-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Message BooCoder..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
{isStreaming ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<Square size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
|
||||||
|
title="Send message"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
|
||||||
|
import type { PendingChange } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffPane({ sessionId, onPendingChange }: Props) {
|
||||||
|
const [changes, setChanges] = useState<PendingChange[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchPending = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.pending.list(sessionId);
|
||||||
|
setChanges(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('fetch pending failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPending();
|
||||||
|
}, [fetchPending]);
|
||||||
|
|
||||||
|
// Listen for WS pending change events
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = onPendingChange((change) => {
|
||||||
|
setChanges((prev) => {
|
||||||
|
const idx = prev.findIndex((c) => c.id === change.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = change;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...prev, change];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [onPendingChange]);
|
||||||
|
|
||||||
|
const pendingChanges = changes.filter((c) => c.status === 'pending');
|
||||||
|
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
|
||||||
|
|
||||||
|
const handleApplyOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.applyOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('apply failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.rejectOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('reject failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRewindOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.rewindOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('rewind failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyAll = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.pending.applyAll(sessionId);
|
||||||
|
const appliedIds = new Set(
|
||||||
|
result.results.filter((r) => r.success).map((r) => r.id),
|
||||||
|
);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('apply all failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectAll = async () => {
|
||||||
|
// Reject each pending change individually (no batch reject endpoint)
|
||||||
|
for (const c of pendingChanges) {
|
||||||
|
await handleRejectOne(c.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
|
||||||
|
switch (op) {
|
||||||
|
case 'create':
|
||||||
|
return <FilePlus size={14} className="text-green-400" />;
|
||||||
|
case 'edit':
|
||||||
|
return <FileText size={14} className="text-blue-400" />;
|
||||||
|
case 'delete':
|
||||||
|
return <Trash2 size={14} className="text-red-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
|
||||||
|
const colors: Record<PendingChange['status'], string> = {
|
||||||
|
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
applied: 'bg-green-500/20 text-green-400',
|
||||||
|
rejected: 'bg-zinc-500/20 text-zinc-400',
|
||||||
|
reverted: 'bg-orange-500/20 text-orange-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300">
|
||||||
|
Pending Changes
|
||||||
|
{pendingChanges.length > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs text-zinc-500">
|
||||||
|
({pendingChanges.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={fetchPending}
|
||||||
|
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{pendingChanges.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyAll}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
|
||||||
|
>
|
||||||
|
Apply All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRejectAll}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
|
||||||
|
>
|
||||||
|
Reject All
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && changes.length === 0 && (
|
||||||
|
<div className="text-center text-zinc-500 text-sm py-8">
|
||||||
|
No pending changes yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending changes first */}
|
||||||
|
{pendingChanges.map((change) => (
|
||||||
|
<ChangeItem
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
expanded={expandedId === change.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||||
|
}
|
||||||
|
onApply={() => handleApplyOne(change.id)}
|
||||||
|
onReject={() => handleRejectOne(change.id)}
|
||||||
|
OpIcon={OpIcon}
|
||||||
|
StatusBadge={StatusBadge}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Resolved changes */}
|
||||||
|
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
|
||||||
|
<div className="border-t border-zinc-800 my-1" />
|
||||||
|
)}
|
||||||
|
{resolvedChanges.map((change) => (
|
||||||
|
<ChangeItem
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
expanded={expandedId === change.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||||
|
}
|
||||||
|
onRewind={
|
||||||
|
change.status === 'applied'
|
||||||
|
? () => handleRewindOne(change.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
OpIcon={OpIcon}
|
||||||
|
StatusBadge={StatusBadge}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeItemProps {
|
||||||
|
change: PendingChange;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onApply?: () => void;
|
||||||
|
onReject?: () => void;
|
||||||
|
onRewind?: () => void;
|
||||||
|
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
|
||||||
|
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeItem({
|
||||||
|
change,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onApply,
|
||||||
|
onReject,
|
||||||
|
onRewind,
|
||||||
|
OpIcon,
|
||||||
|
StatusBadge,
|
||||||
|
}: ChangeItemProps) {
|
||||||
|
const fileName = change.file_path.split('/').pop() || change.file_path;
|
||||||
|
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-zinc-800/50">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<OpIcon op={change.operation} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-mono text-zinc-200 truncate block">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
{dirPath && (
|
||||||
|
<span className="text-[11px] text-zinc-500 truncate block">
|
||||||
|
{dirPath}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={change.status} />
|
||||||
|
{change.status === 'pending' && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApply?.();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-green-600/30 text-green-400"
|
||||||
|
title="Apply"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onReject?.();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-red-600/30 text-red-400"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.status === 'applied' && onRewind && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRewind();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
|
||||||
|
title="Rewind"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
{change.operation === 'edit' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{change.old_string && (
|
||||||
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-red-400 mb-1 font-medium">
|
||||||
|
Remove
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
|
||||||
|
{change.old_string}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.new_string && (
|
||||||
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||||
|
Add
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
|
||||||
|
{change.new_string}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.operation === 'create' && change.content && (
|
||||||
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||||
|
New file
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
|
||||||
|
{change.content.length > 2000
|
||||||
|
? change.content.slice(0, 2000) + '\n... (truncated)'
|
||||||
|
: change.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.operation === 'delete' && (
|
||||||
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||||
|
This file will be deleted.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/coder/web/src/components/Layout.tsx
Normal file
62
apps/coder/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatPane: React.ReactNode;
|
||||||
|
diffPane: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ chatPane, diffPane }: Props) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-zinc-900">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
|
||||||
|
<Code2 size={20} className="text-blue-400" />
|
||||||
|
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile tab bar (visible below lg breakpoint) */}
|
||||||
|
<div className="lg:hidden flex border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chat')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||||
|
activeTab === 'chat'
|
||||||
|
? 'text-blue-400 border-b-2 border-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('diff')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||||
|
activeTab === 'diff'
|
||||||
|
? 'text-blue-400 border-b-2 border-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitPullRequest size={14} />
|
||||||
|
Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop split layout */}
|
||||||
|
<div className="flex-1 hidden lg:flex overflow-hidden">
|
||||||
|
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
|
||||||
|
{chatPane}
|
||||||
|
</div>
|
||||||
|
<div className="w-[40%] overflow-hidden">
|
||||||
|
{diffPane}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: show only the active tab */}
|
||||||
|
<div className="flex-1 lg:hidden overflow-hidden">
|
||||||
|
{activeTab === 'chat' ? chatPane : diffPane}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message }: Props) {
|
||||||
|
if (message.role === 'tool') {
|
||||||
|
return <ToolResultBubble message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const isStreaming = message.status === 'streaming';
|
||||||
|
const isFailed = message.status === 'failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
|
||||||
|
isUser
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isFailed && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
|
||||||
|
<AlertCircle size={12} />
|
||||||
|
<span>Failed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||||
|
<div className="mb-2 space-y-1">
|
||||||
|
{message.tool_calls.map((tc) => (
|
||||||
|
<div
|
||||||
|
key={tc.id}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<Wrench size={11} />
|
||||||
|
<span className="font-mono">{tc.name}</span>
|
||||||
|
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||||
|
{truncateArgs(tc.arguments)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.content.trim() && (
|
||||||
|
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreaming && !message.content.trim() && (
|
||||||
|
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span className="text-xs">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreaming && message.content.trim() && (
|
||||||
|
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolResultBubble({ message }: Props) {
|
||||||
|
const result = message.tool_results;
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const isError = result.error;
|
||||||
|
const output = result.output || '';
|
||||||
|
const displayOutput =
|
||||||
|
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start mb-2 ml-6">
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
|
||||||
|
isError
|
||||||
|
? 'bg-red-950/30 border-red-800/50 text-red-300'
|
||||||
|
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.truncated && (
|
||||||
|
<span className="text-yellow-500 text-[10px] block mb-1">
|
||||||
|
[truncated]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateArgs(args: string): string {
|
||||||
|
if (!args) return '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(args);
|
||||||
|
const keys = Object.keys(parsed);
|
||||||
|
if (keys.length === 0) return '';
|
||||||
|
const first = keys[0]!;
|
||||||
|
const val = String(parsed[first]);
|
||||||
|
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||||
|
return `${first}: ${display}`;
|
||||||
|
} catch {
|
||||||
|
return args.length > 50 ? args.slice(0, 50) + '...' : args;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/coder/web/src/globals.css
Normal file
22
apps/coder/web/src/globals.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for dark theme */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import type { Message, WsFrame, PendingChange } from '@/api/types';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
messages: Message[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFrame(state: State, frame: WsFrame): State {
|
||||||
|
switch (frame.type) {
|
||||||
|
case 'snapshot': {
|
||||||
|
return { ...state, messages: frame.messages };
|
||||||
|
}
|
||||||
|
case 'message_started': {
|
||||||
|
const exists = state.messages.some((m) => m.id === frame.message_id);
|
||||||
|
if (exists) return state;
|
||||||
|
const newMsg: Message = {
|
||||||
|
id: frame.message_id,
|
||||||
|
session_id: '',
|
||||||
|
chat_id: frame.chat_id,
|
||||||
|
role: frame.role,
|
||||||
|
content: '',
|
||||||
|
kind: 'message',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: null,
|
||||||
|
status: frame.role === 'system' ? 'complete' : 'streaming',
|
||||||
|
tokens_used: null,
|
||||||
|
ctx_used: null,
|
||||||
|
ctx_max: null,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'tool_call': {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id
|
||||||
|
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'tool_result': {
|
||||||
|
const exists = state.messages.some((m) => m.id === frame.tool_message_id);
|
||||||
|
if (exists) {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.tool_message_id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
role: 'tool' as const,
|
||||||
|
tool_results: {
|
||||||
|
tool_call_id: frame.tool_call_id,
|
||||||
|
output: frame.output,
|
||||||
|
truncated: frame.truncated,
|
||||||
|
...(frame.error ? { error: frame.error } : {}),
|
||||||
|
},
|
||||||
|
status: 'complete' as const,
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
const newMsg: Message = {
|
||||||
|
id: frame.tool_message_id,
|
||||||
|
session_id: '',
|
||||||
|
chat_id: frame.chat_id,
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
kind: 'message',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: {
|
||||||
|
tool_call_id: frame.tool_call_id,
|
||||||
|
output: frame.output,
|
||||||
|
truncated: frame.truncated,
|
||||||
|
...(frame.error ? { error: frame.error } : {}),
|
||||||
|
},
|
||||||
|
status: 'complete',
|
||||||
|
tokens_used: null,
|
||||||
|
ctx_used: null,
|
||||||
|
ctx_max: null,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
};
|
||||||
|
return { ...state, messages: [...state.messages, newMsg] };
|
||||||
|
}
|
||||||
|
case 'message_complete': {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status: 'complete' as const,
|
||||||
|
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
|
||||||
|
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
|
||||||
|
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
||||||
|
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||||
|
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||||
|
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
const next = frame.message_id
|
||||||
|
? state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m,
|
||||||
|
)
|
||||||
|
: state.messages;
|
||||||
|
return { ...state, messages: next, error: frame.error };
|
||||||
|
}
|
||||||
|
case 'pending_change_added':
|
||||||
|
case 'pending_change_updated':
|
||||||
|
// These are handled by the pending changes listener, not the message state
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_INITIAL_MS = 1000;
|
||||||
|
const RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
interface SessionStreamResult {
|
||||||
|
messages: Message[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
/** Listeners for pending change frames */
|
||||||
|
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
|
||||||
|
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
setState({ messages: [], connected: false, error: null });
|
||||||
|
|
||||||
|
let unmounted = false;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (unmounted) return;
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||||
|
setState((s) => ({ ...s, connected: true, error: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let frame: WsFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify pending change listeners
|
||||||
|
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
|
||||||
|
for (const cb of pendingListenersRef.current) {
|
||||||
|
cb(frame.change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((s) => applyFrame(s, frame));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (unmounted) return;
|
||||||
|
setState((s) => ({ ...s, connected: false }));
|
||||||
|
const delay = reconnectDelay;
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
const ws = wsRef.current;
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ws)
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const isStreaming = state.messages.some((m) => m.status === 'streaming');
|
||||||
|
|
||||||
|
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
|
||||||
|
pendingListenersRef.current.add(cb);
|
||||||
|
return () => {
|
||||||
|
pendingListenersRef.current.delete(cb);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: state.messages,
|
||||||
|
connected: state.connected,
|
||||||
|
error: state.error,
|
||||||
|
isStreaming,
|
||||||
|
onPendingChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
apps/coder/web/src/main.tsx
Normal file
13
apps/coder/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
138
apps/coder/web/src/pages/Home.tsx
Normal file
138
apps/coder/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Code2, Folder, ArrowRight } from 'lucide-react';
|
||||||
|
import type { Project, Session } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
|
export function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch projects on mount
|
||||||
|
useEffect(() => {
|
||||||
|
api.projects
|
||||||
|
.list({ status: 'open' })
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch sessions when a project is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setSessions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.sessions
|
||||||
|
.listForProject(selectedProject, 'open')
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(console.error);
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const handleSessionClick = (session: Session) => {
|
||||||
|
navigate(`/sessions/${session.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||||
|
<div className="text-zinc-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 p-6">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<Code2 size={28} className="text-blue-400" />
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">BooCoder</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
No projects found. Create one in BooChat first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => setSelectedProject(project.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
|
||||||
|
selectedProject === project.id
|
||||||
|
? 'bg-blue-600/20 border border-blue-500/40'
|
||||||
|
: 'bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Folder
|
||||||
|
size={16}
|
||||||
|
className={
|
||||||
|
selectedProject === project.id
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||||
|
{project.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">
|
||||||
|
{project.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions list */}
|
||||||
|
{selectedProject && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||||
|
Sessions
|
||||||
|
</h2>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
No open sessions. Create one in BooChat first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => handleSessionClick(session)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800 text-left transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||||
|
{session.name || 'Untitled session'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{new Date(session.updated_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight
|
||||||
|
size={16}
|
||||||
|
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/coder/web/src/pages/Session.tsx
Normal file
86
apps/coder/web/src/pages/Session.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
|
import { ChatPane } from '@/components/ChatPane';
|
||||||
|
import { DiffPane } from '@/components/DiffPane';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
||||||
|
export function Session() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [chat, setChat] = useState<Chat | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { messages, connected, isStreaming, onPendingChange } =
|
||||||
|
useSessionStream(sessionId);
|
||||||
|
|
||||||
|
// Get or create a chat for this session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
api.chats
|
||||||
|
.listForSession(sessionId)
|
||||||
|
.then((chats) => {
|
||||||
|
// Use the first open chat, or create one
|
||||||
|
const openChat = chats.find((c) => c.status === 'open');
|
||||||
|
if (openChat) {
|
||||||
|
setChat(openChat);
|
||||||
|
} else {
|
||||||
|
// Create a new chat
|
||||||
|
return api.chats.create(sessionId).then((newChat) => {
|
||||||
|
setChat(newChat);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
navigate('/');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||||
|
<div className="text-zinc-500">Loading session...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-zinc-500">Could not load chat for this session.</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
chatPane={
|
||||||
|
<ChatPane
|
||||||
|
sessionId={sessionId}
|
||||||
|
chatId={chat.id}
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
connected={connected}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
diffPane={
|
||||||
|
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
apps/coder/web/tsconfig.app.json
Normal file
27
apps/coder/web/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
apps/coder/web/tsconfig.json
Normal file
13
apps/coder/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/coder/web/tsconfig.node.json
Normal file
14
apps/coder/web/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
25
apps/coder/web/vite.config.js
Normal file
25
apps/coder/web/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
26
apps/coder/web/vite.config.ts
Normal file
26
apps/coder/web/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,23 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||||
|
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||||
|
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||||
|
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||||
|
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||||
|
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||||
|
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||||
|
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||||
|
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||||
|
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||||
|
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||||
|
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||||
|
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||||
|
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||||
|
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
@@ -14,6 +31,7 @@
|
|||||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ai": "^6.0.190",
|
"ai": "^6.0.190",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ConfigSchema = z.object({
|
|||||||
GITEA_USER: z.string().default('indifferentketchup'),
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
GITEA_TOKEN: z.string().optional(),
|
GITEA_TOKEN: z.string().optional(),
|
||||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
|
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
||||||
|
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
||||||
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { listSkills } from './services/skills.js';
|
|||||||
import * as compaction from './services/compaction.js';
|
import * as compaction from './services/compaction.js';
|
||||||
import { configureModelContext } from './services/model-context.js';
|
import { configureModelContext } from './services/model-context.js';
|
||||||
import { cleanupTruncations } from './services/truncate.js';
|
import { cleanupTruncations } from './services/truncate.js';
|
||||||
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
|
import { appendMcpTools } from './services/tools.js';
|
||||||
|
import { refreshToolNames } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -69,6 +73,23 @@ async function main() {
|
|||||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||||
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
||||||
|
|
||||||
|
// v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
|
||||||
|
// Runs before route registration so the tool list is complete when the first
|
||||||
|
// inference request arrives. Per-server graceful degradation: one failing
|
||||||
|
// server doesn't block others.
|
||||||
|
const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
|
||||||
|
const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
|
||||||
|
if (mcpServers.length > 0) {
|
||||||
|
await initMcp(mcpServers, app.log);
|
||||||
|
const mcpTools = getMcpTools();
|
||||||
|
if (mcpTools.length > 0) {
|
||||||
|
appendMcpTools(mcpTools);
|
||||||
|
refreshToolNames();
|
||||||
|
app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
@@ -191,6 +212,37 @@ async function main() {
|
|||||||
});
|
});
|
||||||
registerWebSocket(app, sql, broker);
|
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).
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
|
||||||
|
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
|
||||||
|
* read-only guard, name prefixing, content extraction, and error handling.
|
||||||
|
* Multi-server routing tested via wrapMcpTool's server-name prefix.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
|
||||||
|
|
||||||
|
describe('mcp-client', () => {
|
||||||
|
describe('wrapMcpTool — multi-server prefixing', () => {
|
||||||
|
it('produces a ToolDef with <serverName>_ prefix', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'resolve-library-id',
|
||||||
|
description: 'Resolve a library identifier',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: { query: { type: 'string' } },
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('context7', mcpTool);
|
||||||
|
|
||||||
|
expect(wrapped.name).toBe('context7_resolve-library-id');
|
||||||
|
expect(wrapped.description).toBe('Resolve a library identifier');
|
||||||
|
expect(wrapped.jsonSchema.type).toBe('function');
|
||||||
|
expect(wrapped.jsonSchema.function.name).toBe('context7_resolve-library-id');
|
||||||
|
expect(wrapped.jsonSchema.function.parameters).toEqual(mcpTool.inputSchema);
|
||||||
|
expect(typeof wrapped.execute).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes tools from different servers correctly', () => {
|
||||||
|
const toolA = {
|
||||||
|
name: 'query-docs',
|
||||||
|
description: 'Query docs',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
const toolB = {
|
||||||
|
name: 'overview',
|
||||||
|
description: 'Get overview',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedA = wrapMcpTool('context7', toolA);
|
||||||
|
const wrappedB = wrapMcpTool('codecontext', toolB);
|
||||||
|
|
||||||
|
expect(wrappedA.name).toBe('context7_query-docs');
|
||||||
|
expect(wrappedB.name).toBe('codecontext_overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
|
||||||
|
const serverATools = [
|
||||||
|
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
];
|
||||||
|
const serverBTools = [
|
||||||
|
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allWrapped = [
|
||||||
|
...serverATools.map((t) => wrapMcpTool('context7', t)),
|
||||||
|
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(allWrapped).toHaveLength(4);
|
||||||
|
expect(allWrapped.map((t) => t.name)).toEqual([
|
||||||
|
'context7_query-docs',
|
||||||
|
'context7_resolve-library-id',
|
||||||
|
'codecontext_overview',
|
||||||
|
'codecontext_search',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults description to empty string when absent', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'no-desc',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('myserver', mcpTool);
|
||||||
|
|
||||||
|
expect(wrapped.description).toBe('');
|
||||||
|
expect(wrapped.jsonSchema.function.description).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses passthrough Zod schema (z.record)', () => {
|
||||||
|
const mcpTool = {
|
||||||
|
name: 'test',
|
||||||
|
inputSchema: { type: 'object' as const, properties: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapped = wrapMcpTool('s', mcpTool);
|
||||||
|
|
||||||
|
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isToolReadOnly', () => {
|
||||||
|
it('accepts tools with readOnlyHint: true', () => {
|
||||||
|
expect(isToolReadOnly({ readOnlyHint: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with no annotations', () => {
|
||||||
|
expect(isToolReadOnly(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with empty annotations', () => {
|
||||||
|
expect(isToolReadOnly({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tools with readOnlyHint: false', () => {
|
||||||
|
expect(isToolReadOnly({ readOnlyHint: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tools with only destructiveHint set', () => {
|
||||||
|
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractContent', () => {
|
||||||
|
it('extracts single text block', () => {
|
||||||
|
const content = [{ type: 'text', text: 'hello world' }];
|
||||||
|
expect(extractContent(content)).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins multiple text blocks with newline', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'text', text: 'line 1' },
|
||||||
|
{ type: 'text', text: 'line 2' },
|
||||||
|
];
|
||||||
|
expect(extractContent(content)).toBe('line 1\nline 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "(no output)" for empty content', () => {
|
||||||
|
expect(extractContent([])).toBe('(no output)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "(no output)" for undefined content', () => {
|
||||||
|
expect(extractContent(undefined)).toBe('(no output)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes non-text blocks as JSON', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'resource', uri: 'file:///foo', mimeType: 'text/plain' },
|
||||||
|
];
|
||||||
|
const result = extractContent(content);
|
||||||
|
expect(result).toContain('"type":"resource"');
|
||||||
|
expect(result).toContain('"uri":"file:///foo"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error shape when isError is true', () => {
|
||||||
|
const content = [{ type: 'text', text: 'something failed' }];
|
||||||
|
const result = extractContent(content, true);
|
||||||
|
expect(result).toEqual({ error: true, output: 'something failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error shape with joined content on isError', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'text', text: 'error 1' },
|
||||||
|
{ type: 'text', text: 'error 2' },
|
||||||
|
];
|
||||||
|
const result = extractContent(content, true);
|
||||||
|
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
|
|
||||||
|
describe('matchToolGlob', () => {
|
||||||
|
it('exact match: "grep" matches "grep"', () => {
|
||||||
|
expect(matchToolGlob('grep', ['grep'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exact match: "grep" does not match "grep2"', () => {
|
||||||
|
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exact match: multiple tools', () => {
|
||||||
|
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
|
||||||
|
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
|
||||||
|
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
|
||||||
|
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
|
||||||
|
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard: "*" matches everything', () => {
|
||||||
|
expect(matchToolGlob('anything', ['*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deny: "!web_*" excludes "web_search"', () => {
|
||||||
|
// With only a deny rule and no prior match, the tool is not matched
|
||||||
|
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
|
||||||
|
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
|
||||||
|
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
|
||||||
|
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last-match-wins: deny then re-allow', () => {
|
||||||
|
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
|
||||||
|
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
|
||||||
|
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty patterns: nothing matches', () => {
|
||||||
|
expect(matchToolGlob('grep', [])).toBe(false);
|
||||||
|
expect(matchToolGlob('anything', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
|
||||||
|
const patterns = ['grep', 'view_file'];
|
||||||
|
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('view_file', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', patterns)).toBe(false);
|
||||||
|
expect(matchToolGlob('web_search', patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed glob and exact patterns', () => {
|
||||||
|
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
|
||||||
|
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
|
||||||
|
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
|
||||||
|
expect(matchToolGlob('view_file', patterns)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
|
|||||||
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||||
// codecontext tools were missing), silently filtering valid tool names out
|
// codecontext tools were missing), silently filtering valid tool names out
|
||||||
// of agents that opted in. Single source of truth is tools.ts now.
|
// of agents that opted in. Single source of truth is tools.ts now.
|
||||||
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
|
|
||||||
|
export function refreshToolNames(): void {
|
||||||
|
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
||||||
|
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
|
||||||
|
}
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|
||||||
|
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple glob match for tool names. Supports `*` as a wildcard for any
|
||||||
|
* characters. No `?` or `**` — tool names are flat (no path separators).
|
||||||
|
*/
|
||||||
|
function simpleGlobMatch(str: string, pattern: string): boolean {
|
||||||
|
if (pattern === '*') return true;
|
||||||
|
if (!pattern.includes('*')) return str === pattern;
|
||||||
|
// Escape regex metacharacters, then replace escaped \* with .*
|
||||||
|
const regex = new RegExp(
|
||||||
|
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||||
|
);
|
||||||
|
return regex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool name matches a set of glob patterns. Last-match-wins.
|
||||||
|
* Patterns starting with `!` are deny rules.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
|
||||||
|
* - `["context7_*"]` — all tools from the context7 MCP server
|
||||||
|
* - `["*", "!web_*"]` — all tools except web tools
|
||||||
|
* - `[]` — nothing matches (agent gets no tools)
|
||||||
|
*/
|
||||||
|
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
|
||||||
|
let matched = false;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const deny = pattern.startsWith('!');
|
||||||
|
const glob = deny ? pattern.slice(1) : pattern;
|
||||||
|
if (simpleGlobMatch(toolName, glob)) {
|
||||||
|
matched = !deny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a tools: entry is a glob pattern (contains * or starts
|
||||||
|
* with !). Glob patterns can't be validated against the current tool list
|
||||||
|
* since MCP tools are discovered at runtime.
|
||||||
|
*/
|
||||||
|
function isGlobPattern(entry: string): boolean {
|
||||||
|
return entry.includes('*') || entry.startsWith('!');
|
||||||
|
}
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
export function slugify(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -37,6 +89,10 @@ interface ParsedFrontmatter {
|
|||||||
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
||||||
// from the agent's toolset at runtime.
|
// from the agent's toolset at runtime.
|
||||||
max_tool_calls?: number;
|
max_tool_calls?: number;
|
||||||
|
// v1.14.0: optional per-agent step cap. Absent → bounded only by MAX_STEPS
|
||||||
|
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||||
|
// allowed" — the model responds text-only.
|
||||||
|
steps?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
function stripQuotes(s: string): string {
|
||||||
@@ -112,6 +168,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
||||||
}
|
}
|
||||||
|
} else if (key === 'steps') {
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. Integer ≥ 0.
|
||||||
|
// steps: 0 means "no tool calls allowed" — model responds text-only.
|
||||||
|
// Non-integer or negative values are warned and ignored (falls back to
|
||||||
|
// MAX_STEPS ceiling), matching the max_tool_calls pattern above.
|
||||||
|
const n = Number(valueRaw);
|
||||||
|
if (Number.isInteger(n) && n >= 0) {
|
||||||
|
data.steps = n;
|
||||||
|
} else if (Number.isInteger(n)) {
|
||||||
|
console.warn(
|
||||||
|
`agents: steps ${n} is negative, ignoring (falling back to default)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Unknown keys silently ignored — forward-compat.
|
// Unknown keys silently ignored — forward-compat.
|
||||||
}
|
}
|
||||||
@@ -188,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
|
|
||||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||||
|
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
|
||||||
|
// pass through unvalidated — MCP tools are discovered at runtime and can't
|
||||||
|
// be checked against ALL_TOOL_NAMES at parse time.
|
||||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||||
const filteredTools = Array.isArray(fm.tools)
|
const filteredTools = Array.isArray(fm.tools)
|
||||||
? fm.tools.filter((t): t is string =>
|
? fm.tools.filter((t): t is string =>
|
||||||
(ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t),
|
isGlobPattern(t) ||
|
||||||
|
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
|
||||||
)
|
)
|
||||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||||
|
|
||||||
@@ -204,6 +279,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
tools: filteredTools,
|
tools: filteredTools,
|
||||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||||
|
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { InferenceContext } from './inference/index.js';
|
import type { InferenceContext } from './inference/index.js';
|
||||||
|
|
||||||
const NAMING_SYSTEM_PROMPT =
|
const NAMING_SYSTEM_PROMPT =
|
||||||
'You name chat sessions. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
||||||
|
|
||||||
const MAX_TITLE_CHARS = 60;
|
const MAX_TITLE_CHARS = 60;
|
||||||
|
|
||||||
@@ -70,12 +70,6 @@ export async function maybeAutoNameChat(
|
|||||||
const model = sessionRows[0]?.model;
|
const model = sessionRows[0]?.model;
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
|
|
||||||
const userMsg = await ctx.sql<{ content: string }[]>`
|
|
||||||
SELECT content FROM messages
|
|
||||||
WHERE chat_id = ${chatId} AND role = 'user'
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
||||||
SELECT content FROM messages
|
SELECT content FROM messages
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
@@ -85,9 +79,8 @@ export async function maybeAutoNameChat(
|
|||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
if (!userMsg[0] || !assistantMsg[0]) return;
|
if (!assistantMsg[0]) return;
|
||||||
|
|
||||||
const userText = userMsg[0].content.slice(0, 2000);
|
|
||||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -96,7 +89,7 @@ export async function maybeAutoNameChat(
|
|||||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `First user message: ${userText}\nFirst assistant reply: ${assistantText}`,
|
content: assistantText,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 30,
|
max_tokens: 30,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createInferenceRunner,
|
createInferenceRunner,
|
||||||
|
MAX_STEPS,
|
||||||
runAssistantTurn,
|
runAssistantTurn,
|
||||||
runInference,
|
runInference,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
@@ -16,5 +17,6 @@ export type {
|
|||||||
StreamResult,
|
StreamResult,
|
||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
|
export type { ToolPhaseResult } from './tool-phase.js';
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
export { buildMessagesPayload } from './payload.js';
|
export { buildMessagesPayload } from './payload.js';
|
||||||
|
|||||||
@@ -476,6 +476,202 @@ export async function runDoomLoopSummary(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: step-cap wrap-up. Mirrors runCapHitSummary structurally — same
|
||||||
|
// in-flight-slot reuse, same tools-disabled streaming-summary call, same
|
||||||
|
// post-finalize sentinel insert + chat_status drop. Difference: the note
|
||||||
|
// text names the step limit rather than the tool budget. Sentinel reuses
|
||||||
|
// metadata.kind = 'cap_hit' so the frontend CapHitSentinel component
|
||||||
|
// renders it without changes.
|
||||||
|
const STEP_CAP_NOTE = (steps: number, cap: number) =>
|
||||||
|
`You've reached the step limit (${steps}/${cap} steps). Produce the best answer you can with what you have. Do not call more tools.`;
|
||||||
|
|
||||||
|
export async function runStepCapSummary(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
project: Project,
|
||||||
|
history: Message[],
|
||||||
|
agent: Agent | null,
|
||||||
|
steps: number,
|
||||||
|
cap: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||||
|
|
||||||
|
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||||
|
messages.push({ role: 'system', content: STEP_CAP_NOTE(steps, cap) });
|
||||||
|
|
||||||
|
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
||||||
|
UPDATE messages
|
||||||
|
SET started_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
RETURNING started_at
|
||||||
|
`;
|
||||||
|
const startedAt = startedRow[0]?.started_at ?? null;
|
||||||
|
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
});
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
||||||
|
let flushPromise: Promise<unknown> = Promise.resolve();
|
||||||
|
const flushNow = () => {
|
||||||
|
if (pendingFlushTimer) {
|
||||||
|
clearTimeout(pendingFlushTimer);
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
}
|
||||||
|
const snapshot = accumulated;
|
||||||
|
flushPromise = flushPromise.then(() =>
|
||||||
|
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (pendingFlushTimer) return;
|
||||||
|
pendingFlushTimer = setTimeout(() => {
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
flushNow();
|
||||||
|
}, DB_FLUSH_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
let summaryOk = false;
|
||||||
|
let summarySoftCancelled = false;
|
||||||
|
let summaryError: string | null = null;
|
||||||
|
let result: StreamResult | null = null;
|
||||||
|
try {
|
||||||
|
result = await streamCompletion(
|
||||||
|
ctx,
|
||||||
|
session.model,
|
||||||
|
messages,
|
||||||
|
{ tools: null, temperature: agent?.temperature },
|
||||||
|
(delta) => {
|
||||||
|
accumulated += delta;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: delta,
|
||||||
|
});
|
||||||
|
scheduleFlush();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
summaryOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
summarySoftCancelled = true;
|
||||||
|
} else {
|
||||||
|
summaryError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pendingFlushTimer) {
|
||||||
|
clearTimeout(pendingFlushTimer);
|
||||||
|
pendingFlushTimer = null;
|
||||||
|
}
|
||||||
|
await flushPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryOk && result) {
|
||||||
|
const mctx = await modelContext.getModelContext(session.model);
|
||||||
|
const nCtx = mctx?.n_ctx ?? null;
|
||||||
|
const [updated] = await ctx.sql<
|
||||||
|
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||||
|
>`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${result.content},
|
||||||
|
status = 'complete',
|
||||||
|
tokens_used = ${result.completionTokens},
|
||||||
|
ctx_used = ${result.promptTokens},
|
||||||
|
ctx_max = ${nCtx},
|
||||||
|
finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tokens_used: updated?.tokens_used ?? null,
|
||||||
|
ctx_used: updated?.ctx_used ?? null,
|
||||||
|
ctx_max: updated?.ctx_max ?? null,
|
||||||
|
started_at: startedAt,
|
||||||
|
finished_at: updated?.finished_at ?? null,
|
||||||
|
model: session.model,
|
||||||
|
});
|
||||||
|
} else if (summarySoftCancelled) {
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${accumulated},
|
||||||
|
status = 'cancelled',
|
||||||
|
finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errMeta: MessageMetadata = {
|
||||||
|
kind: 'error',
|
||||||
|
error_reason: 'summary_after_cap_failed',
|
||||||
|
error_text: summaryError ?? 'step-cap summary failed',
|
||||||
|
};
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${accumulated},
|
||||||
|
status = 'failed',
|
||||||
|
finished_at = clock_timestamp(),
|
||||||
|
metadata = ${ctx.sql.json(errMeta as never)}
|
||||||
|
WHERE id = ${assistantMessageId}
|
||||||
|
`;
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'error',
|
||||||
|
message_id: assistantMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
error: summaryError ?? 'step-cap summary failed',
|
||||||
|
reason: 'summary_after_cap_failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||||
|
UPDATE sessions SET updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${sessionId}
|
||||||
|
RETURNING project_id, name, updated_at
|
||||||
|
`;
|
||||||
|
ctx.publishUser({
|
||||||
|
type: 'session_updated',
|
||||||
|
session_id: sessionId,
|
||||||
|
project_id: sessRow!.project_id,
|
||||||
|
name: sessRow!.name,
|
||||||
|
updated_at: sessRow!.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reuse cap_hit sentinel so the frontend CapHitSentinel component renders
|
||||||
|
// it without changes. The content text distinguishes step cap from budget.
|
||||||
|
await insertCapHitSentinel(ctx, sessionId, chatId, agent, cap);
|
||||||
|
|
||||||
|
if (summaryOk || summarySoftCancelled) {
|
||||||
|
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
||||||
|
} else {
|
||||||
|
ctx.publishUser({
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: chatId,
|
||||||
|
status: 'error',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
reason: 'summary_after_cap_failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, assistantMessageId, steps, cap, summaryOk, summaryCancelled: summarySoftCancelled },
|
||||||
|
'inference step-cap summary finished',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function insertDoomLoopSentinel(
|
async function insertDoomLoopSentinel(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '../../types/api.js';
|
} from '../../types/api.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import type { OpenAiMessage } from './payload.js';
|
import type { OpenAiMessage } from './payload.js';
|
||||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
||||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
||||||
@@ -376,14 +377,14 @@ export async function executeStreamPhase(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
||||||
// tool names it allows. Unknown names in agent.tools are dropped silently
|
// tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
|
||||||
// (handled here by intersection). When no agent: send all tools.
|
// pattern support (e.g. `context7_*`, `!web_*`). When no agent: send all tools.
|
||||||
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
||||||
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
||||||
// model can't summon a tool that wasn't offered to it.
|
// model can't summon a tool that wasn't offered to it.
|
||||||
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
||||||
const effectiveTools: ToolJsonSchema[] = (agent
|
const effectiveTools: ToolJsonSchema[] = (agent
|
||||||
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name))
|
? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
|
||||||
: toolJsonSchemas()
|
: toolJsonSchemas()
|
||||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||||
const effectiveTemperature = agent?.temperature;
|
const effectiveTemperature = agent?.temperature;
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ import type {
|
|||||||
StreamResult,
|
StreamResult,
|
||||||
TurnArgs,
|
TurnArgs,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
// v1.12.4: ESM value-import cycle. executeToolPhase recurses into
|
|
||||||
// runAssistantTurn which lives in inference.ts. The cycle is safe because
|
|
||||||
// the reference is read at call time (inside an async function body), not
|
|
||||||
// at module top-level. Node + tsc resolve this cleanly.
|
|
||||||
import { runAssistantTurn } from './turn.js';
|
|
||||||
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
||||||
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
||||||
// recursion on synthesis failure (timeout / model error). See module header
|
// recursion on synthesis failure (timeout / model error). See module header
|
||||||
@@ -86,6 +81,16 @@ async function executeToolCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: return struct from executeToolPhase so the caller (the outer
|
||||||
|
// while loop in turn.ts) can decide whether to continue, break, or handle
|
||||||
|
// synthesis. Replaces the recursive call into runAssistantTurn.
|
||||||
|
export interface ToolPhaseResult {
|
||||||
|
action: 'continue' | 'paused' | 'synthesis_done';
|
||||||
|
toolCallCount: number;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
nextAssistantId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeToolPhase(
|
export async function executeToolPhase(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
args: TurnArgs,
|
args: TurnArgs,
|
||||||
@@ -93,8 +98,8 @@ export async function executeToolPhase(
|
|||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string
|
||||||
): Promise<void> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
const { content, toolCalls, promptTokens, completionTokens } = result;
|
||||||
|
|
||||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||||
@@ -296,7 +301,12 @@ export async function executeToolPhase(
|
|||||||
{ sessionId, chatId, assistantMessageId },
|
{ sessionId, chatId, assistantMessageId },
|
||||||
'inference paused awaiting user input',
|
'inference paused awaiting user input',
|
||||||
);
|
);
|
||||||
return;
|
return {
|
||||||
|
action: 'paused' as const,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
toolCalls,
|
||||||
|
nextAssistantId: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
||||||
@@ -328,30 +338,30 @@ export async function executeToolPhase(
|
|||||||
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
||||||
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
||||||
});
|
});
|
||||||
if (ran) return;
|
if (ran) {
|
||||||
|
return {
|
||||||
|
action: 'synthesis_done' as const,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
toolCalls,
|
||||||
|
nextAssistantId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
// ran === false → synthesis failed (timeout / model error) → fall through
|
// ran === false → synthesis failed (timeout / model error) → fall through
|
||||||
// to the standard recursive turn below. The synth message (if created)
|
// to the standard continue path below. The synth message (if created)
|
||||||
// was already marked status='failed' inside runSynthesisPass.
|
// was already marked status='failed' inside runSynthesisPass.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.14.0: create the next assistant row and return a continue result.
|
||||||
|
// The caller (outer while loop in turn.ts) handles the iteration.
|
||||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
await runAssistantTurn(ctx, {
|
return {
|
||||||
sessionId,
|
action: 'continue' as const,
|
||||||
chatId,
|
toolCallCount: toolCalls.length,
|
||||||
assistantMessageId: nextAssistant!.id,
|
toolCalls,
|
||||||
// v1.8.2: charge this turn's actual tool invocations against the budget.
|
nextAssistantId: nextAssistant!.id,
|
||||||
// One assistant message can emit multiple tool_calls, so we add the run
|
};
|
||||||
// count, not 1. The next turn's budget check sees the cumulative total.
|
|
||||||
toolsUsed: toolsUsed + result.toolCalls.length,
|
|
||||||
// v1.11.6: append the just-executed tool calls to the per-turn history
|
|
||||||
// so the next runAssistantTurn's doom-loop check can see them. We don't
|
|
||||||
// cap the array length here — per-turn budgets keep it bounded
|
|
||||||
// (typically <30 entries), and slicing happens inside detectDoomLoop.
|
|
||||||
recentToolCalls: [...args.recentToolCalls, ...result.toolCalls],
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import { resolveProjectRoot } from '../path_guard.js';
|
|||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import * as modelContext from '../model-context.js';
|
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
import { resolveToolBudget } from './budget.js';
|
import { resolveToolBudget } from './budget.js';
|
||||||
import {
|
import {
|
||||||
DOOM_LOOP_THRESHOLD,
|
|
||||||
detectDoomLoop,
|
detectDoomLoop,
|
||||||
} from './sentinels.js';
|
} from './sentinels.js';
|
||||||
import {
|
import {
|
||||||
@@ -33,15 +31,23 @@ import {
|
|||||||
} from './error-handler.js';
|
} from './error-handler.js';
|
||||||
import {
|
import {
|
||||||
executeStreamPhase,
|
executeStreamPhase,
|
||||||
streamCompletion,
|
|
||||||
} from './stream-phase.js';
|
} from './stream-phase.js';
|
||||||
import { executeToolPhase } from './tool-phase.js';
|
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
|
||||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
import type { StreamPhaseState } from './types.js';
|
||||||
import {
|
import {
|
||||||
runCapHitSummary,
|
runCapHitSummary,
|
||||||
runDoomLoopSummary,
|
runDoomLoopSummary,
|
||||||
|
runStepCapSummary,
|
||||||
} from './sentinel-summaries.js';
|
} from './sentinel-summaries.js';
|
||||||
|
|
||||||
|
// v1.14.0: hard ceiling on the number of stream-and-tool iterations per
|
||||||
|
// user-message turn. Per-agent cap via agent.steps is the primary knob;
|
||||||
|
// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling
|
||||||
|
// (50 tool calls) — in practice budget fires first unless the model makes
|
||||||
|
// many 0-tool-call iterations (which exit the loop via the non-tool finish
|
||||||
|
// path anyway).
|
||||||
|
export const MAX_STEPS = 200;
|
||||||
|
|
||||||
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
||||||
// importing from services/inference.js as the public surface.
|
// importing from services/inference.js as the public surface.
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
@@ -145,75 +151,185 @@ export async function runAssistantTurn(
|
|||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
args: TurnArgs,
|
args: TurnArgs,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId } = args;
|
const { sessionId, chatId, signal } = args;
|
||||||
|
|
||||||
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
// v1.14.0: resolve agent once at the top. The agent stays fixed for the
|
||||||
// so loadContext below reads the post-compaction history. We swallow
|
// duration of this user-message turn — PATCH agent_id mid-conversation
|
||||||
// compaction failures (clearing the flag so we don't loop) and proceed
|
// takes effect on the next runInference, not mid-loop.
|
||||||
// with the un-compacted history — a slow turn that hits the model's
|
const initialLoaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
// hard limit is recoverable; a dead session is not.
|
if (!initialLoaded) {
|
||||||
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
|
||||||
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
|
||||||
`;
|
|
||||||
if (chatFlag[0]?.needs_compaction) {
|
|
||||||
try {
|
|
||||||
await compaction.process({
|
|
||||||
sql: ctx.sql,
|
|
||||||
config: ctx.config,
|
|
||||||
log: ctx.log,
|
|
||||||
broker: ctx.broker,
|
|
||||||
chatId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
|
||||||
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
|
||||||
if (!loaded) {
|
|
||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { session, project, history } = loaded;
|
const { session, project } = initialLoaded;
|
||||||
const projectRoot = await resolveProjectRoot(project.path);
|
|
||||||
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
|
||||||
// effect on the next message. Unknown agent_id returns null silently —
|
|
||||||
// session falls back to base prompt + all tools + default temperature.
|
|
||||||
const agent = session.agent_id
|
const agent = session.agent_id
|
||||||
? await getAgentById(project.path, session.agent_id)
|
? await getAgentById(project.path, session.agent_id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
|
|
||||||
// When we've already burned the budget *before* this turn even runs, we
|
|
||||||
// skip straight to the summary flow — the in-flight assistant message slot
|
|
||||||
// gets reused for the wrap-up reply instead of being marked failed.
|
|
||||||
const budget = resolveToolBudget(agent);
|
const budget = resolveToolBudget(agent);
|
||||||
if (args.toolsUsed >= budget) {
|
|
||||||
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
|
// v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS).
|
||||||
|
// steps: 0 means "no tool calls allowed" — the first stream phase runs
|
||||||
|
// but if it emits tool calls they are not executed (finalize as text-only).
|
||||||
|
const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS);
|
||||||
|
|
||||||
|
// steps: 0 special case — model responds text-only. The while loop would
|
||||||
|
// never enter (effectiveCap === 0), so we handle it explicitly before the
|
||||||
|
// loop. The model always gets at least one chance to respond with text.
|
||||||
|
if (effectiveCap === 0) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
|
let stepNumber = 0;
|
||||||
// burn through 3 identical calls long before the 15-call budget fires).
|
let toolsUsed = args.toolsUsed;
|
||||||
// Same in-flight-slot-reuse pattern as runCapHitSummary — wrap-up reply
|
let recentToolCalls = args.recentToolCalls;
|
||||||
// lands in args.assistantMessageId, then a doom_loop sentinel is inserted
|
let assistantMessageId = args.assistantMessageId;
|
||||||
// to make the abort visible in the chat history.
|
|
||||||
const loop = detectDoomLoop(args.recentToolCalls);
|
while (stepNumber < effectiveCap) {
|
||||||
if (loop) {
|
// ---- doom-loop check (moved from top-of-function) ----
|
||||||
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
|
const loop = detectDoomLoop(recentToolCalls);
|
||||||
return;
|
if (loop) {
|
||||||
|
// Need fresh history for the summary.
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- budget check (moved from top-of-function) ----
|
||||||
|
if (toolsUsed >= budget) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compaction check ----
|
||||||
|
// v1.11: if the prior turn flagged this chat for compaction, run it
|
||||||
|
// before loadContext so we read post-compaction history. Swallow
|
||||||
|
// failures and proceed with un-compacted history.
|
||||||
|
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||||
|
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatFlag[0]?.needs_compaction) {
|
||||||
|
try {
|
||||||
|
await compaction.process({
|
||||||
|
sql: ctx.sql,
|
||||||
|
config: ctx.config,
|
||||||
|
log: ctx.log,
|
||||||
|
broker: ctx.broker,
|
||||||
|
chatId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
||||||
|
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- load context (must re-load each iteration — new messages since last step) ----
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (!loaded) {
|
||||||
|
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { session: iterSession, project: iterProject, history } = loaded;
|
||||||
|
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||||
|
|
||||||
|
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||||
|
// the schema CHECK but not emitted here — writing to the assistant message
|
||||||
|
// before the stream phase creates a sequence-0 collision with
|
||||||
|
// partsFromAssistantMessage. A WS frame or structured log is sufficient
|
||||||
|
// since the frontend doesn't render step boundaries in v1.14.
|
||||||
|
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
|
||||||
|
|
||||||
|
// ---- build messages + stream phase ----
|
||||||
|
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
|
||||||
|
const webToolsEnabled =
|
||||||
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
|
let result: StreamResult;
|
||||||
|
try {
|
||||||
|
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
|
||||||
|
} catch (err) {
|
||||||
|
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- non-tool finish → finalize and exit ----
|
||||||
|
if (result.toolCalls.length === 0) {
|
||||||
|
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- steps: 0 edge case ----
|
||||||
|
// effectiveCap check above guarantees we're inside the loop, but this
|
||||||
|
// guard handles the theoretical case where the model emits tool calls
|
||||||
|
// on step 0 when effectiveCap would have been 0 (impossible since the
|
||||||
|
// while condition prevents entry, but kept for safety). If effectiveCap
|
||||||
|
// is 1 and we're on step 0, tool calls ARE executed — steps counts
|
||||||
|
// iterations, not post-first-stream.
|
||||||
|
|
||||||
|
// ---- tool phase ----
|
||||||
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
|
try {
|
||||||
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
||||||
|
} catch (err) {
|
||||||
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- update loop locals ----
|
||||||
|
toolsUsed += toolPhaseResult.toolCallCount;
|
||||||
|
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
|
||||||
|
stepNumber++;
|
||||||
|
|
||||||
|
if (toolPhaseResult.action !== 'continue') {
|
||||||
|
// 'paused' (user input) or 'synthesis_done' — stop the loop.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 'continue' — advance to next assistant message.
|
||||||
|
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- post-loop: step-cap sentinel ----
|
||||||
|
// When the loop exits because stepNumber reached effectiveCap, the last
|
||||||
|
// iteration's tool phase returned 'continue' with a nextAssistantId that
|
||||||
|
// is still in 'streaming' status (unfilled). Use it for the wrap-up.
|
||||||
|
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
|
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.14.0: special handling for steps: 0 — the model responds text-only.
|
||||||
|
// The while loop never enters (effectiveCap === 0). We stream once with
|
||||||
|
// no tools, finalize, and return. If the model emits tool calls despite
|
||||||
|
// not being offered tools, they're ignored (finalize as text-only).
|
||||||
|
async function runTextOnlyTurn(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
project: Project,
|
||||||
|
history: Message[],
|
||||||
|
agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||||
|
// Web tools are irrelevant when steps: 0 (no tool execution), but we
|
||||||
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
|
// still need to resolve the flag for executeStreamPhase's signature.
|
||||||
// - session.web_search_enabled = null → inherit project default
|
|
||||||
// - session.web_search_enabled = true/false → explicit
|
|
||||||
// Both web_search and web_fetch are gated by this single flag (the UI
|
|
||||||
// label is "Enable web search and fetch" — same store, both tools).
|
|
||||||
// Default is false unless explicitly opted in, matching the v1.9
|
|
||||||
// plumbing intent ("inert until Batch 8 ships the actual tools").
|
|
||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
@@ -227,8 +343,12 @@ export async function runAssistantTurn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolCalls.length > 0) {
|
if (result.toolCalls.length > 0) {
|
||||||
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
ctx.log.warn(
|
||||||
return;
|
{ chatId: args.chatId, toolCallCount: result.toolCalls.length },
|
||||||
|
'steps: 0 agent emitted tool calls; ignoring and finalizing as text-only',
|
||||||
|
);
|
||||||
|
// Override: strip tool calls so finalizeCompletion treats it as text-only.
|
||||||
|
result = { ...result, toolCalls: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||||
|
|||||||
288
apps/server/src/services/mcp-client.ts
Normal file
288
apps/server/src/services/mcp-client.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: multi-server MCP client registry.
|
||||||
|
*
|
||||||
|
* Connects to multiple MCP servers (Streamable HTTP or stdio transport),
|
||||||
|
* discovers tools from each, wraps them as BooCode ToolDefs with a
|
||||||
|
* `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
|
||||||
|
*
|
||||||
|
* Graceful degradation: one failing server doesn't block others.
|
||||||
|
* Read-only invariant: tools with readOnlyHint === false are rejected.
|
||||||
|
*/
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import type { McpServerEntry, McpServerConfig } from './mcp-config.js';
|
||||||
|
import type { ToolDef } from './tools.js';
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
|
||||||
|
interface McpToolAnnotations {
|
||||||
|
readOnlyHint?: boolean;
|
||||||
|
destructiveHint?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpToolDef {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
annotations?: McpToolAnnotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerState {
|
||||||
|
client: Client;
|
||||||
|
transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||||
|
tools: ToolDef<Record<string, unknown>>[];
|
||||||
|
type: 'streamableHttp' | 'stdio';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Module-level state ----
|
||||||
|
|
||||||
|
const servers = new Map<string, ServerState>();
|
||||||
|
// Reverse map: prefixed tool name → server name (built during discovery)
|
||||||
|
const toolToServer = new Map<string, string>();
|
||||||
|
let log: FastifyBaseLogger | null = null;
|
||||||
|
|
||||||
|
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to all configured MCP servers, discover tools, and wrap them.
|
||||||
|
* Per-server graceful degradation: a failing server is logged and skipped.
|
||||||
|
*/
|
||||||
|
export async function initialize(
|
||||||
|
entries: McpServerEntry[],
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
log = logger;
|
||||||
|
|
||||||
|
// Connect servers in parallel — each wrapped in try/catch for isolation
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
await connectServer(entry);
|
||||||
|
} catch (err) {
|
||||||
|
log!.warn(
|
||||||
|
{ err, server: entry.name },
|
||||||
|
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (servers.size > 0) {
|
||||||
|
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
|
||||||
|
log.info(
|
||||||
|
{ servers: servers.size, tools: totalTools },
|
||||||
|
'mcp: multi-server initialization complete',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an MCP tool by its prefixed name. Routes to the correct server
|
||||||
|
* using the toolToServer reverse map.
|
||||||
|
*/
|
||||||
|
export async function callTool(
|
||||||
|
prefixedName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const serverName = toolToServer.get(prefixedName);
|
||||||
|
if (!serverName) {
|
||||||
|
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = servers.get(serverName);
|
||||||
|
if (!state) {
|
||||||
|
return { error: true, output: `MCP server "${serverName}" not available` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the "<serverName>_" prefix to get the original tool name
|
||||||
|
const originalName = prefixedName.slice(serverName.length + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await state.client.callTool({ name: originalName, arguments: args });
|
||||||
|
|
||||||
|
const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||||
|
if (!content || content.length === 0) {
|
||||||
|
return '(no output)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
const joined = content
|
||||||
|
.map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block)))
|
||||||
|
.join('\n');
|
||||||
|
return { error: true, output: joined || '(MCP error with no details)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = content.map((block) => {
|
||||||
|
if (block.type === 'text') return block.text ?? '';
|
||||||
|
return JSON.stringify(block);
|
||||||
|
});
|
||||||
|
const joined = parts.join('\n');
|
||||||
|
if (joined.length > MAX_RESULT_BYTES) {
|
||||||
|
log?.warn({ tool: originalName, server: serverName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||||
|
return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]';
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed');
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
output: err instanceof Error ? err.message : 'MCP server unreachable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
||||||
|
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
||||||
|
const all: ToolDef<Record<string, unknown>>[] = [];
|
||||||
|
for (const state of servers.values()) {
|
||||||
|
all.push(...state.tools);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return status of each server (for debug/status endpoints). */
|
||||||
|
export function getMcpServers(): Array<{
|
||||||
|
name: string;
|
||||||
|
type: 'streamableHttp' | 'stdio';
|
||||||
|
toolCount: number;
|
||||||
|
connected: boolean;
|
||||||
|
}> {
|
||||||
|
return Array.from(servers.entries()).map(([name, state]) => ({
|
||||||
|
name,
|
||||||
|
type: state.type,
|
||||||
|
toolCount: state.tools.length,
|
||||||
|
connected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown. For stdio servers, the SDK's transport.close() handles
|
||||||
|
* SIGTERM + timeout. For HTTP servers, close the transport.
|
||||||
|
*/
|
||||||
|
export async function shutdown(): Promise<void> {
|
||||||
|
const closePromises: Promise<void>[] = [];
|
||||||
|
for (const [name, state] of servers) {
|
||||||
|
closePromises.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await state.transport.close();
|
||||||
|
log?.info({ server: name }, 'mcp: server transport closed');
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn({ err, server: name }, 'mcp: error closing server transport');
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
servers.clear();
|
||||||
|
toolToServer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal helpers ----
|
||||||
|
|
||||||
|
async function connectServer(entry: McpServerEntry): Promise<void> {
|
||||||
|
const { name, config } = entry;
|
||||||
|
|
||||||
|
const client = new Client({ name: 'boocode', version: '1.15.0' });
|
||||||
|
let transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||||
|
|
||||||
|
if (config.type === 'streamableHttp') {
|
||||||
|
transport = createHttpTransport(config);
|
||||||
|
} else {
|
||||||
|
transport = createStdioTransport(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.connect(transport);
|
||||||
|
|
||||||
|
const result = await client.listTools();
|
||||||
|
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||||
|
|
||||||
|
const tools: ToolDef<Record<string, unknown>>[] = [];
|
||||||
|
for (const t of mcpTools) {
|
||||||
|
if (t.annotations?.readOnlyHint === false) {
|
||||||
|
log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wrapped = wrapMcpTool(name, t);
|
||||||
|
tools.push(wrapped);
|
||||||
|
toolToServer.set(wrapped.name, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.set(name, { client, transport, tools, type: config.type });
|
||||||
|
|
||||||
|
log!.info(
|
||||||
|
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },
|
||||||
|
'mcp: server initialized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpTransport(config: Extract<McpServerConfig, { type: 'streamableHttp' }>): StreamableHTTPClientTransport {
|
||||||
|
const requestInit: RequestInit = {};
|
||||||
|
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||||
|
requestInit.headers = config.headers;
|
||||||
|
}
|
||||||
|
return new StreamableHTTPClientTransport(new URL(config.url), { requestInit });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStdioTransport(config: Extract<McpServerConfig, { type: 'stdio' }>): StdioClientTransport {
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */
|
||||||
|
export function wrapMcpTool(
|
||||||
|
serverName: string,
|
||||||
|
mcpTool: McpToolDef,
|
||||||
|
): ToolDef<Record<string, unknown>> {
|
||||||
|
const prefixedName = `${serverName}_${mcpTool.name}`;
|
||||||
|
return {
|
||||||
|
name: prefixedName,
|
||||||
|
description: mcpTool.description ?? '',
|
||||||
|
inputSchema: z.record(z.unknown()),
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: prefixedName,
|
||||||
|
description: mcpTool.description ?? '',
|
||||||
|
parameters: mcpTool.inputSchema ?? { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (input) => {
|
||||||
|
return callTool(prefixedName, input);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed for unit tests — extract content from an MCP result. */
|
||||||
|
export function extractContent(
|
||||||
|
content: Array<{ type: string; text?: string; [key: string]: unknown }> | undefined,
|
||||||
|
isError?: boolean,
|
||||||
|
): unknown {
|
||||||
|
if (!content || content.length === 0) return '(no output)';
|
||||||
|
|
||||||
|
const parts = content.map((block) => {
|
||||||
|
if (block.type === 'text') return block.text ?? '';
|
||||||
|
return JSON.stringify(block);
|
||||||
|
});
|
||||||
|
const joined = parts.join('\n');
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return { error: true, output: joined || '(MCP error with no details)' };
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed for unit tests — the read-only guard predicate. */
|
||||||
|
export function isToolReadOnly(annotations?: McpToolAnnotations): boolean {
|
||||||
|
return annotations?.readOnlyHint !== false;
|
||||||
|
}
|
||||||
78
apps/server/src/services/mcp-config.ts
Normal file
78
apps/server/src/services/mcp-config.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* v1.15.0-mcp-multi: MCP config file schema + loader.
|
||||||
|
*
|
||||||
|
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
|
||||||
|
* servers — their transport type, connection parameters, and enabled state.
|
||||||
|
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
|
// ---- Zod schema ----
|
||||||
|
|
||||||
|
const McpServerConfigSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('streamableHttp'),
|
||||||
|
url: z.string().url(),
|
||||||
|
headers: z.record(z.string()).optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('stdio'),
|
||||||
|
command: z.string().min(1),
|
||||||
|
args: z.array(z.string()).default([]),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const McpConfigSchema = z.object({
|
||||||
|
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||||
|
|
||||||
|
export interface McpServerEntry {
|
||||||
|
name: string;
|
||||||
|
config: McpServerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Loader ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and validate the MCP config file. Returns enabled servers only.
|
||||||
|
* File missing → log info, return []. Parse/validation error → log warn, return [].
|
||||||
|
*/
|
||||||
|
export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(configPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
log.info(`mcp: config not found at ${configPath}, skipping`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = McpConfigSchema.safeParse(json);
|
||||||
|
if (!result.success) {
|
||||||
|
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: McpServerEntry[] = [];
|
||||||
|
for (const [name, config] of Object.entries(result.data.mcpServers)) {
|
||||||
|
if (config.enabled) {
|
||||||
|
entries.push({ name, config });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
watchChanges,
|
watchChanges,
|
||||||
getSemanticNeighborhoods,
|
getSemanticNeighborhoods,
|
||||||
getFrameworkAnalysis,
|
getFrameworkAnalysis,
|
||||||
|
getBlastRadius,
|
||||||
|
getHotFiles,
|
||||||
|
getRoutes,
|
||||||
|
getMiddleware,
|
||||||
} from './tools/codecontext/index.js';
|
} from './tools/codecontext/index.js';
|
||||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
@@ -651,7 +655,9 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
|
|||||||
// of the system prompt, so any order drift would invalidate every cached
|
// of the system prompt, so any order drift would invalidate every cached
|
||||||
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
||||||
// and TOOLS_BY_NAME inherit it.
|
// and TOOLS_BY_NAME inherit it.
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array
|
||||||
|
// so appendMcpTools() can push MCP-discovered tools at startup.
|
||||||
|
export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
viewTruncatedOutput as ToolDef<unknown>,
|
viewTruncatedOutput as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
@@ -678,6 +684,11 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
watchChanges as ToolDef<unknown>,
|
watchChanges as ToolDef<unknown>,
|
||||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||||
getFrameworkAnalysis as ToolDef<unknown>,
|
getFrameworkAnalysis as ToolDef<unknown>,
|
||||||
|
// v1.16: codesight-merge tools. Backed by the same codecontext sidecar.
|
||||||
|
getBlastRadius as ToolDef<unknown>,
|
||||||
|
getHotFiles as ToolDef<unknown>,
|
||||||
|
getRoutes as ToolDef<unknown>,
|
||||||
|
getMiddleware as ToolDef<unknown>,
|
||||||
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||||
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||||
// state change is appending to sessions.allowed_read_paths via the
|
// state change is appending to sessions.allowed_read_paths via the
|
||||||
@@ -725,10 +736,23 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
'request_read_access',
|
'request_read_access',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
ALL_TOOLS.map((t) => [t.name, t])
|
ALL_TOOLS.map((t) => [t.name, t])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once
|
||||||
|
// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and
|
||||||
|
// rebuilds TOOLS_BY_NAME. READ_ONLY_TOOL_NAMES is not rebuilt because
|
||||||
|
// it's a const tuple used only for budget-tier checks; MCP tools are
|
||||||
|
// individually checked via their category at budget resolution time —
|
||||||
|
// they are all read_only by construction (the read-only guard in
|
||||||
|
// mcp-client.ts rejects any tool with readOnlyHint: false).
|
||||||
|
export function appendMcpTools(mcpTools: ToolDef<unknown>[]): void {
|
||||||
|
if (mcpTools.length === 0) return;
|
||||||
|
ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t]));
|
||||||
|
}
|
||||||
|
|
||||||
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
||||||
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
||||||
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetBlastRadiusInput = z.object({
|
||||||
|
file_path: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
export type GetBlastRadiusInputT = z.infer<typeof GetBlastRadiusInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns all files that depend (transitively) on the given file, with depth tracking. ' +
|
||||||
|
'Use to assess the impact of changing a file — "what breaks if I modify this?" ' +
|
||||||
|
'Traverses the import graph in reverse via BFS. Results sorted by distance (closest dependents first).';
|
||||||
|
|
||||||
|
export async function executeGetBlastRadius(
|
||||||
|
input: GetBlastRadiusInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext(
|
||||||
|
{ toolName: 'get_blast_radius', args: { file_path: input.file_path }, projectPath },
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBlastRadius: ToolDef<GetBlastRadiusInputT> = {
|
||||||
|
name: 'get_blast_radius',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetBlastRadiusInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_blast_radius',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Absolute or project-relative path to the file to analyze.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetBlastRadius(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetHotFilesInput = z.object({
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
export type GetHotFilesInputT = z.infer<typeof GetHotFilesInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns the most-imported files in the project, ranked by incoming import count. ' +
|
||||||
|
'Hot files are high-risk change targets — many other files depend on them. ' +
|
||||||
|
'Use to identify core modules and assess refactoring risk.';
|
||||||
|
|
||||||
|
export async function executeGetHotFiles(
|
||||||
|
input: GetHotFilesInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext(
|
||||||
|
{ toolName: 'get_hot_files', args: input.limit != null ? { limit: input.limit } : {}, projectPath },
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHotFiles: ToolDef<GetHotFilesInputT> = {
|
||||||
|
name: 'get_hot_files',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetHotFilesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_hot_files',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of files to return (default 20, max 100).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetHotFiles(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetMiddlewareInput = z.object({});
|
||||||
|
export type GetMiddlewareInputT = z.infer<typeof GetMiddlewareInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Detects middleware registrations in the project. Identifies auth, CORS, rate-limit, ' +
|
||||||
|
'security-headers, error-handler, logging, and validation middleware by analyzing ' +
|
||||||
|
'import names (@fastify/cors, helmet, etc.) and registration patterns ' +
|
||||||
|
'(app.register, app.addHook, app.setErrorHandler).';
|
||||||
|
|
||||||
|
export async function executeGetMiddleware(
|
||||||
|
_input: GetMiddlewareInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
return callCodecontext({ toolName: 'get_middleware', args: {}, projectPath }, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMiddleware: ToolDef<GetMiddlewareInputT> = {
|
||||||
|
name: 'get_middleware',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetMiddlewareInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_middleware',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetMiddleware(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../../tools.js';
|
||||||
|
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
export const GetRoutesInput = z.object({
|
||||||
|
framework: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
export type GetRoutesInputT = z.infer<typeof GetRoutesInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Extracts HTTP routes from the project via tree-sitter AST analysis. ' +
|
||||||
|
'Detects Fastify and Express route registrations (app.get, app.post, app.route, router.use, etc.) ' +
|
||||||
|
'with method, path, file, line number, and inferred tags (db, auth, cache). ' +
|
||||||
|
'Optional framework filter narrows to "fastify" or "express".';
|
||||||
|
|
||||||
|
export async function executeGetRoutes(
|
||||||
|
input: GetRoutesInputT,
|
||||||
|
projectPath: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
if (input.framework) args.framework = input.framework;
|
||||||
|
return callCodecontext({ toolName: 'get_routes', args, projectPath }, fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRoutes: ToolDef<GetRoutesInputT> = {
|
||||||
|
name: 'get_routes',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetRoutesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_routes',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
framework: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter to a specific framework: "fastify" or "express". Omit for all.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return await executeGetRoutes(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// v1.12 Track B.2: codecontext tool registry. Re-exports the 8 ToolDefs so
|
// codecontext tool registry. Re-exports ToolDefs so tools.ts can pull them
|
||||||
// tools.ts can pull them in one line.
|
// in one line. v1.12: 8 original tools. v1.16: +4 codesight-merge tools.
|
||||||
|
|
||||||
export { getCodebaseOverview } from './get_codebase_overview.js';
|
export { getCodebaseOverview } from './get_codebase_overview.js';
|
||||||
export { getFileAnalysis } from './get_file_analysis.js';
|
export { getFileAnalysis } from './get_file_analysis.js';
|
||||||
@@ -9,3 +9,7 @@ export { getDependencies } from './get_dependencies.js';
|
|||||||
export { watchChanges } from './watch_changes.js';
|
export { watchChanges } from './watch_changes.js';
|
||||||
export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js';
|
export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js';
|
||||||
export { getFrameworkAnalysis } from './get_framework_analysis.js';
|
export { getFrameworkAnalysis } from './get_framework_analysis.js';
|
||||||
|
export { getBlastRadius } from './get_blast_radius.js';
|
||||||
|
export { getHotFiles } from './get_hot_files.js';
|
||||||
|
export { getRoutes } from './get_routes.js';
|
||||||
|
export { getMiddleware } from './get_middleware.js';
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export interface Session {
|
|||||||
export type WorkspacePaneKind =
|
export type WorkspacePaneKind =
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'terminal'
|
| 'terminal'
|
||||||
| 'agent'
|
| 'coder'
|
||||||
| 'empty'
|
| 'empty'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'markdown_artifact'
|
| 'markdown_artifact'
|
||||||
@@ -106,6 +106,9 @@ export interface Agent {
|
|||||||
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
|
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
|
||||||
// raw chat with no agent.
|
// raw chat with no agent.
|
||||||
max_tool_calls: number | null;
|
max_tool_calls: number | null;
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||||
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
|
steps: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"declaration": false,
|
"declaration": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export interface Agent {
|
|||||||
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
|
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
|
||||||
// chat with no agent.
|
// chat with no agent.
|
||||||
max_tool_calls: number | null;
|
max_tool_calls: number | null;
|
||||||
|
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||||
|
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||||
|
steps: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentParseError {
|
export interface AgentParseError {
|
||||||
@@ -323,7 +326,7 @@ export interface AskUserAnswerSet {
|
|||||||
export type WorkspacePaneKind =
|
export type WorkspacePaneKind =
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'terminal'
|
| 'terminal'
|
||||||
| 'agent'
|
| 'coder'
|
||||||
| 'empty'
|
| 'empty'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'markdown_artifact'
|
| 'markdown_artifact'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Bot, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { StatusDot } from '@/components/StatusDot';
|
import { StatusDot } from '@/components/StatusDot';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
onCloseToRight: (chatId: string) => void;
|
onCloseToRight: (chatId: string) => void;
|
||||||
onCloseAll: () => void;
|
onCloseAll: () => void;
|
||||||
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
onRename: (chatId: string, name: string) => Promise<void>;
|
onRename: (chatId: string, name: string) => Promise<void>;
|
||||||
onRemovePane?: () => void;
|
onRemovePane?: () => void;
|
||||||
@@ -188,8 +188,8 @@ export function ChatTabBar({
|
|||||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||||
<Terminal size={14} /> New terminal
|
<Terminal size={14} /> New terminal
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
|
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||||
<Bot size={14} /> New agent
|
<Code size={14} /> New coder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Code,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Edit2,
|
Edit2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -43,7 +43,7 @@ const SWIPE_VISUAL_CAP = 120;
|
|||||||
|
|
||||||
function paneIcon(kind: WorkspacePane['kind']) {
|
function paneIcon(kind: WorkspacePane['kind']) {
|
||||||
if (kind === 'terminal') return <Terminal size={14} />;
|
if (kind === 'terminal') return <Terminal size={14} />;
|
||||||
if (kind === 'agent') return <Bot size={14} />;
|
if (kind === 'coder') return <Code size={14} />;
|
||||||
if (kind === 'settings') return <SettingsIcon size={14} />;
|
if (kind === 'settings') return <SettingsIcon size={14} />;
|
||||||
return <MessageSquare size={14} />;
|
return <MessageSquare size={14} />;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
|
|||||||
}
|
}
|
||||||
if (pane.kind === 'chat') return 'Chat';
|
if (pane.kind === 'chat') return 'Chat';
|
||||||
if (pane.kind === 'terminal') return 'Terminal';
|
if (pane.kind === 'terminal') return 'Terminal';
|
||||||
if (pane.kind === 'agent') return 'Agent';
|
if (pane.kind === 'coder') return 'Coder';
|
||||||
if (pane.kind === 'settings') return 'Settings';
|
if (pane.kind === 'settings') return 'Settings';
|
||||||
return 'Empty';
|
return 'Empty';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, MessageSquare, Plus, Terminal } from 'lucide-react';
|
import { Code, MessageSquare, Plus, Terminal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -7,14 +7,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
|
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
|
||||||
// Terminal and Agent items pass through to addSplitPane which already shows
|
// Terminal + Coder items pass through to addSplitPane which creates panes
|
||||||
// "coming soon" toasts; rendering them here matches the Batch 3 workspace
|
// of the appropriate kind.
|
||||||
// model so the UI is forward-compatible with BooTerm/BooCoder.
|
|
||||||
export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -35,8 +34,8 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
|||||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||||
<Terminal size={14} /> New terminal
|
<Terminal size={14} /> New terminal
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
|
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||||
<Bot size={14} /> New agent
|
<Code size={14} /> New coder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, Plus, X } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
||||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
@@ -8,6 +8,7 @@ import { terminalsRegistry } from '@/lib/events';
|
|||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||||
|
import { CoderPane } from '@/components/panes/CoderPane';
|
||||||
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||||
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
@@ -160,8 +161,8 @@ export function Workspace({
|
|||||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||||
<Terminal size={14} /> Terminal
|
<Terminal size={14} /> Terminal
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||||
<Bot size={14} /> Agent
|
<Code size={14} /> Coder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -264,8 +265,8 @@ export function Workspace({
|
|||||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||||
<Terminal size={14} /> New terminal
|
<Terminal size={14} /> New terminal
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||||
<Bot size={14} /> New agent
|
<Code size={14} /> New coder
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -321,6 +322,8 @@ export function Workspace({
|
|||||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||||
active={idx === activePaneIdx}
|
active={idx === activePaneIdx}
|
||||||
/>
|
/>
|
||||||
|
) : pane.kind === 'coder' ? (
|
||||||
|
<CoderPane sessionId={sessionId} />
|
||||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||||
<MarkdownArtifactPane
|
<MarkdownArtifactPane
|
||||||
chatId={pane.markdown_artifact_state.chat_id}
|
chatId={pane.markdown_artifact_state.chat_id}
|
||||||
|
|||||||
432
apps/web/src/components/panes/CoderPane.tsx
Normal file
432
apps/web/src/components/panes/CoderPane.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface 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.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||||
|
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CoderMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
status?: 'streaming' | 'complete' | 'failed';
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id: string;
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}>;
|
||||||
|
tool_results?: {
|
||||||
|
tool_call_id: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'modify' | 'delete';
|
||||||
|
diff?: string;
|
||||||
|
new_content?: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useCoderMessages(sessionId: string) {
|
||||||
|
const [messages, setMessages] = useState<CoderMessage[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// WS connects to the coder backend. In production, this goes through the
|
||||||
|
// same host (BooChat serves the SPA and proxies). In dev, Vite proxy
|
||||||
|
// handles /api/coder/ws/* -> boocoder:9502.
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => setConnected(true);
|
||||||
|
ws.onclose = () => setConnected(false);
|
||||||
|
|
||||||
|
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' },
|
||||||
|
]);
|
||||||
|
} else if (frame.type === 'delta') {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === frame.message_id
|
||||||
|
? { ...m, content: m.content + (frame.content ?? '') }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (frame.type === 'message_complete') {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === frame.message_id ? { ...m, status: 'complete' } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (frame.type === 'tool_call') {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unparseable frames
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ws.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return { messages, setMessages, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePendingChanges(sessionId: string) {
|
||||||
|
const [changes, setChanges] = useState<PendingChange[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/coder/sessions/${sessionId}/pending`)
|
||||||
|
.then((res) => res.ok ? res.json() : [])
|
||||||
|
.then((data: PendingChange[]) => setChanges(data))
|
||||||
|
.catch(() => {/* noop */})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const approve = useCallback(async (changeId: string) => {
|
||||||
|
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const reject = useCallback(async (changeId: string) => {
|
||||||
|
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return { changes, loading, refresh, approve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
|
onRefresh,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
changes: PendingChange[];
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onApprove: (id: string) => void;
|
||||||
|
onReject: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const pending = changes.filter((c) => c.status === 'pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full border-t border-border">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Pending Changes {pending.length > 0 && `(${pending.length})`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Refresh pending changes"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||||
|
No pending changes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{pending.map((change) => (
|
||||||
|
<div key={change.id} className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-mono text-foreground truncate flex-1 mr-2">
|
||||||
|
<span className={cn(
|
||||||
|
'inline-block w-1.5 h-1.5 rounded-full mr-1.5',
|
||||||
|
change.operation === 'create' && 'bg-green-500',
|
||||||
|
change.operation === 'modify' && 'bg-yellow-500',
|
||||||
|
change.operation === 'delete' && 'bg-red-500',
|
||||||
|
)} />
|
||||||
|
{change.file_path}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApprove(change.id)}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded bg-green-500/10 hover:bg-green-500/20 text-green-600 dark:text-green-400"
|
||||||
|
aria-label="Approve change"
|
||||||
|
title="Approve"
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReject(change.id)}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400"
|
||||||
|
aria-label="Reject change"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{change.diff && (
|
||||||
|
<pre className="text-[11px] font-mono bg-muted/50 rounded p-2 overflow-x-auto max-h-32 whitespace-pre">
|
||||||
|
{change.diff}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CoderPane({ sessionId }: Props) {
|
||||||
|
const { messages, setMessages, connected } = useCoderMessages(sessionId);
|
||||||
|
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') {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}, [messages, refresh]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
|
});
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The WS will bring the real messages; optimistic is good enough
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [input, sending, sessionId, setMessages]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend]
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-sm text-muted-foreground gap-2">
|
||||||
|
<Code size={32} className="opacity-40" />
|
||||||
|
<p>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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff panel — only shows when there are pending changes */}
|
||||||
|
{changes.filter((c) => c.status === 'pending').length > 0 && (
|
||||||
|
<div className="h-48 shrink-0">
|
||||||
|
<DiffPanel
|
||||||
|
changes={changes}
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={refresh}
|
||||||
|
onApprove={approve}
|
||||||
|
onReject={reject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="shrink-0 border-t border-border p-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask BooCoder to write code..."
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,13 @@ function terminalPane(id: string = generateId()): WorkspacePane {
|
|||||||
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
// SettingsPane component renders Session/Project sections from the
|
// SettingsPane component renders Session/Project sections from the
|
||||||
// surrounding session/project.
|
// surrounding session/project.
|
||||||
@@ -109,10 +116,10 @@ export interface UseWorkspacePanesResult {
|
|||||||
closeAllTabs: (paneIdx: number) => void;
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
showLandingPage: (paneIdx: number) => void;
|
showLandingPage: (paneIdx: number) => void;
|
||||||
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
|
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
|
||||||
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
|
// max panes reached). Callers can use the
|
||||||
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
||||||
// freshly-set activePaneIdx.
|
// freshly-set activePaneIdx.
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
|
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
|
||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
@@ -388,11 +395,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
|
||||||
if (kind === 'agent') {
|
|
||||||
toast('Agent panes coming in BooCoder');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Generate the id outside the updater so we can return it deterministically.
|
// Generate the id outside the updater so we can return it deterministically.
|
||||||
// setPanes's updater can be invoked twice in strict mode; using a fixed id
|
// setPanes's updater can be invoked twice in strict mode; using a fixed id
|
||||||
// ensures both invocations agree and the returned id matches what landed.
|
// ensures both invocations agree and the returned id matches what landed.
|
||||||
@@ -404,7 +407,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
|
const newPane =
|
||||||
|
kind === 'terminal' ? terminalPane(newPaneId) :
|
||||||
|
kind === 'coder' ? coderPane(newPaneId) :
|
||||||
|
emptyPane(newPaneId);
|
||||||
const next = [...prev, newPane];
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
success = true;
|
success = true;
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
// the new pane's id to the URL atomically so the effect's next pass sees a
|
// the new pane's id to the URL atomically so the effect's next pass sees a
|
||||||
// matching id and is a no-op. Desktop has no URL pane state — fall through.
|
// matching id and is a no-op. Desktop has no URL pane state — fall through.
|
||||||
const addPaneAndSwitch = useCallback(
|
const addPaneAndSwitch = useCallback(
|
||||||
(kind: 'chat' | 'terminal' | 'agent') => {
|
(kind: 'chat' | 'terminal' | 'coder') => {
|
||||||
const newPaneId = addSplitPane(kind);
|
const newPaneId = addSplitPane(kind);
|
||||||
if (newPaneId === null) return;
|
if (newPaneId === null) return;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ export default defineConfig({
|
|||||||
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// BooCoder: proxy /api/coder/* to the coder container. Must be listed
|
||||||
|
// before /api so the more-specific prefix matches first.
|
||||||
|
'/api/coder': {
|
||||||
|
target: process.env.BOOCODER_DEV_URL ?? 'http://127.0.0.1:9502',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: (path: string) => path.replace(/^\/api\/coder/, '/api'),
|
||||||
|
headers: {
|
||||||
|
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BooCode v1.x — Roadmap
|
# BooCode v1.x — Roadmap
|
||||||
|
|
||||||
Last updated: 2026-05-22
|
Last updated: 2026-05-23
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
## Shipped (status as of 2026-05-22)
|
## Shipped (status as of 2026-05-23)
|
||||||
|
|
||||||
|Version |Theme |Tag |
|
|Version |Theme |Tag |
|
||||||
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
||||||
@@ -72,9 +72,9 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
### Shipped (v1.13.x — written 2026-05-22, retagged same day)
|
### Shipped (v1.13.x — strangler-fig closed 2026-05-23)
|
||||||
|
|
||||||
All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). Tip is `v1.13.14-skills-audit` (`0fa46cd`); the next batch is `v1.13.15-codecontext-synth` (this batch, tag pending). Tags in chronological order:
|
All v1.13.x batches use the `vMAJOR.MINOR.PATCH-slug` tag scheme adopted 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). The v1.13.x line ran 21 batches over a single intense window; the umbrella `v1.13` tag sits on `211e903` (same commit as `v1.13.20-drop-legacy-cols`), marking the strangler-fig closed. Tags in chronological order:
|
||||||
|
|
||||||
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
|
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
|
||||||
- `v1.13.1-cleanup-bundle` — `statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
|
- `v1.13.1-cleanup-bundle` — `statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
|
||||||
@@ -93,115 +93,13 @@ All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 202
|
|||||||
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
|
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
|
||||||
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
|
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
|
||||||
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
|
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
|
||||||
|
- `v1.13.17-cross-repo-reads` — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extended with `extraRoots`; pause/resume reuses the `ask_user_input` mechanism
|
||||||
|
- `v1.13.18-codecontext-file-path` — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` arg the same way `target_dir` was; closes the silent-fail path the sidecar exhibited on relative paths
|
||||||
|
- `v1.13.19-html-artifact-panes` — pane-based artifact viewer with on-request HTML; `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; Markdown + HTML panes both open via "Open in pane" affordance; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`, `srcDoc`); CSP `connect-src 'none'`. Scope-revised mid-design from auto-bias-to-HTML to Markdown-default / HTML-on-request
|
||||||
|
- `v1.13.20-drop-legacy-cols` — final strangler-fig step. Drops `messages.tool_calls` + `tool_results` columns; 10 dual-write sites removed (recon caught 2 beyond the original roadmap inventory); `messages_with_parts` view simplified to parts-only subselects via `CREATE OR REPLACE` before the column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had a `RETURNING tool_calls, tool_results` clause; fixed via two-step UPDATE-then-SELECT-from-view. `Message` API type retains the fields — view synthesizes them from parts so the wire shape is unchanged
|
||||||
|
- `v1.13` — **umbrella tag on the same commit as v1.13.20.** Marks the AI SDK v6 + parts-table migration complete
|
||||||
|
|
||||||
The remaining strangler-fig final step (drop `messages.tool_calls` + `tool_results` columns) is still pending under its old `v1.13.2` working name; will get a new tag slug when scoped.
|
The v1.13.x line is closed. Three batches still sit in the **In flight** column conceptually but none of them are v1.13.x scope: **live-smoke of v1.13.19** (manual browser exercise of the artifact panes — five minutes, independent), and the two v1.14 branches below. Independent siblings (`v1.14.x-mcp`, `v1.14.x-html`, `v1.16`) can ship in any order relative to v1.14 itself.
|
||||||
|
|
||||||
## In flight / next (v1.13.x cleanup line)
|
|
||||||
|
|
||||||
Five more single-dispatch batches before the strangler-fig closes. Each ships independently with its own smoke and rollback surface. **Do not fold.** Order is locked:
|
|
||||||
|
|
||||||
### v1.13.8 — system-prompt prefix stability verify-and-measure (REFRAMED, 2026-05-22)
|
|
||||||
|
|
||||||
**Original plan:** add a `system_prompt_cache` DB table keyed by `(agent_id, project_id, skills_version)`, mtime-invalidated.
|
|
||||||
|
|
||||||
**Why reframed:** recon disproved the premise. `apps/server/src/services/system-prompt.ts:buildSystemPrompt` already runs over mtime-cached inputs at the file layer:
|
|
||||||
|
|
||||||
- BOOCHAT.md / BOOCODER.md cached in `system-prompt.ts:25` (`cachedGuidance`, keyed by mtime)
|
|
||||||
- global + per-project AGENTS.md cached in `agents.ts:245` (`safeStat` pattern, 60s TTL)
|
|
||||||
- `session.system_prompt` / `project.default_system_prompt` are DB scalars (byte-stable until edited)
|
|
||||||
- BASE_SYSTEM_PROMPT is a hardcoded template with `${projectPath}` interpolation
|
|
||||||
|
|
||||||
Output assembly is a microsecond pure-string concat with no I/O. Skills aren't in the prefix (runtime discovery via `skill_find`). Tools live in a separate request body field, alpha-sorted by v1.13.3. **In theory the prefix is already byte-stable across turns; nothing has measured it.**
|
|
||||||
|
|
||||||
**New scope — instrumentation only, no cache:**
|
|
||||||
|
|
||||||
1. SHA-256 fingerprint of `buildSystemPrompt`'s output logged per turn at `level=info`, msg `prefix-fingerprint`, with project_id / agent_id / session_id / prefix_hash / prefix_length / mtime fields.
|
|
||||||
2. Module-level `Map<sessionId, lastHash>` observer. On hash change for a known session → emit `prefix-drift` at `level=warn` with `prev_hash`, `new_hash`, and a field-level `changed_inputs` diff.
|
|
||||||
3. Unit-level byte-stability assertion in `system-prompt.test.ts`: two consecutive `buildSystemPrompt` calls with the same inputs return byte-identical strings.
|
|
||||||
|
|
||||||
**Decision criterion:** smoke 5 turns in a fresh session. 5 identical hashes + zero drift logs → close v1.13.8 as no-op, **drop the DB cache plan permanently**, move to v1.13.9. If drift surfaces → characterize the failure mode in a follow-up batch (the answer may not be a cache at all).
|
|
||||||
|
|
||||||
**Doctrine:** matches the v1.13.6 audit pattern. Don't add infrastructure without a proven cache miss. The v1.12.0 mtime caches at the input layer plus alpha tool ordering at the request body layer already address the load-bearing cache-stability surfaces.
|
|
||||||
|
|
||||||
**Dispatch brief:** `handoff_v1.13.8_prefix_verify.md`.
|
|
||||||
|
|
||||||
**Estimated:** ~95 LoC (system-prompt.ts + small `getAgentsMtimes` accessor in agents.ts + 3 new tests).
|
|
||||||
|
|
||||||
### v1.13.9 — compaction overflow trigger formula
|
|
||||||
|
|
||||||
opencode pattern: `0.85 * ctx_max` early trigger (not at 100% saturation). Reduces tail-loss risk and gives compaction a safer window. Tiny change but tied to v1.13.4's tier logic — sequence matters.
|
|
||||||
|
|
||||||
**Lift source:** `anomalyco/opencode` `session/overflow.ts`.
|
|
||||||
|
|
||||||
**Estimated:** ~30 LoC.
|
|
||||||
|
|
||||||
### v1.13.10 — per-tool token cost accounting
|
|
||||||
|
|
||||||
Rolling average per tool, surfaced in AgentPicker tooltip + agent-pick decisions. Backend tracks `(tool_name, prompt_tokens_in, completion_tokens_out)` per call; surfaces a 100-call rolling mean. Frontend reads it for tool-cost hints. **Depends on v1.13.7's `includeUsage` fix** — without real token numbers in DB rows, the rolling average is empty.
|
|
||||||
|
|
||||||
**Estimated:** ~250 LoC.
|
|
||||||
|
|
||||||
### v1.13.11 — WebSocket frame typing
|
|
||||||
|
|
||||||
Zod schemas validated both ends. Catches the recurring class of bug that drove the 2026-05-21 debugging spike (silent protocol drift). Upfront work that pays back every time the protocol changes. `chat_status`, `usage`, `parts_appended`, `session_workspace_updated`, `tool_running` — every frame gets a Zod schema, every send/receive site validates.
|
|
||||||
|
|
||||||
**Estimated:** ~300 LoC.
|
|
||||||
|
|
||||||
### v1.13.12 — skills audit pass (NEW, 2026-05-22)
|
|
||||||
|
|
||||||
**Goal:** apply the rules→recipes split (per Codeminer42 activation-gap data: plain skills invoke 6% in clean multi-turn, `CLAUDE.md`/`AGENTS.md` is 100% present) to BooCode's 7 vendored v1.12 skills. Sort each into: (a) move to `AGENTS.md` as always-true rule, (b) keep as recipe invoked via `/skill <name>`, (c) move bulky context into `references/` flat subdirectory inside the skill, (d) delete (Claude already does it reliably).
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
1. **Audit each of the 7 vendored skills against the 4-way split.** Most workflow-rule content ("always do X before Y", "never do Z") moves to `AGENTS.md` since it should be 100% present. Recipe content ("here's how to scaffold a component", "here's the release checklist") stays as skill, gets `context: fork` if heavy.
|
|
||||||
1. **Adopt Anthropic best-practices conventions** for any skills that remain after audit: gerund names (`scaffolding-components`, not `component-helper`), SKILL.md ≤500 lines, references one level deep, third-person imperative voice, MCP tool references in `ServerName:tool_name` format, no Windows-style paths, no time-sensitive info, consistent terminology, no "voodoo constants."
|
|
||||||
1. **Run each remaining skill through the 4-step validation protocol** from `mgechev/skills-best-practices` (Discovery → Logic → Edge Case → Architecture Refinement) using a fresh Claude chat per step. Prompts are paste-ready; ~10 minutes per skill.
|
|
||||||
1. **Install `skillgrade` on Sam's host** (`npm i -g skillgrade`). For each remaining skill, write a minimal `eval.yaml` with 2–3 tasks and run `skillgrade --smoke` (5 trials, ~5 min) to confirm the skill triggers when expected and produces correct output. **Likely outcome: some skills show 0–20% trigger rate — confirms they belong in AGENTS.md, not as skills.**
|
|
||||||
1. **Document the rules→recipes split as a BooCode convention** in `BOOCODER.md` / `BOOCHAT.md`. Future-proofs against re-adding workflow rules as skills.
|
|
||||||
|
|
||||||
**Lift sources:**
|
|
||||||
|
|
||||||
- `blog.codeminer42.com/stop-putting-best-practices-in-skills/` — empirical 6%/33%/66%/100% invocation-rate data with Vercel-style multi-turn methodology. The activation-gap framing.
|
|
||||||
- `mgechev/skills-best-practices` (25 stars, MIT) — 4-step validation protocol with paste-ready prompts. Directory structure conventions.
|
|
||||||
- `mgechev/skillgrade` (132 stars, MIT) — agent-agnostic skill eval framework. `eval.yaml` task+grader schema. Smoke/reliable/regression presets.
|
|
||||||
- `platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices` — canonical Anthropic standard. 500-line ceiling, gerund naming, progressive disclosure patterns, MCP tool reference format, verification checklist.
|
|
||||||
|
|
||||||
**Dependencies:** none (the 7 v1.12 skills already exist; this is an audit pass on shipped material). Can ship at any point in the v1.13.x line.
|
|
||||||
|
|
||||||
**Estimated:** zero code changes, ~one evening of audit work, plus skillgrade install. Per-skill eval.yaml authoring is ~30 min per skill including the 4-step validation. Total roughly 5–6 hours of focused work for all 7 skills.
|
|
||||||
|
|
||||||
### v1.13.2 — drop legacy columns (final phase of strangler-fig)
|
|
||||||
|
|
||||||
**Wait at least one week of production traffic on v1.13.1 before shipping.** The dual-write is rollback insurance. Drop the columns and that rollback is gone.
|
|
||||||
|
|
||||||
**Verification query before shipping:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
COUNT(*) FILTER (WHERE m.tool_calls IS NOT NULL AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_call'
|
|
||||||
)) AS missing_tool_call_parts,
|
|
||||||
COUNT(*) FILTER (WHERE m.tool_results IS NOT NULL AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_result'
|
|
||||||
)) AS missing_tool_result_parts
|
|
||||||
FROM messages m
|
|
||||||
WHERE m.created_at > '2026-05-22'::timestamptz;
|
|
||||||
```
|
|
||||||
|
|
||||||
Both columns must read 0.
|
|
||||||
|
|
||||||
**Scope (~150 LoC, mostly deletions):**
|
|
||||||
|
|
||||||
1. Remove dual-write from every v1.13.0 site: `tool-phase.ts` (3 sites), `finalizeCompletion`, `skills.ts` (2 sites), `messages.ts` answer flow, `chats.ts` (fork). Keep only the parts write.
|
|
||||||
1. Simplify `messages_with_parts` view — drop COALESCE fallbacks since legacy columns are about to disappear.
|
|
||||||
1. `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results`.
|
|
||||||
1. Remove `tool_calls`/`tool_results` fields from `Message` API type. API boundary unchanged (frontend already reads parts-derived values).
|
|
||||||
1. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 schema if still present.
|
|
||||||
1. Update test fixtures in `inference.test.ts` and `compaction.test.ts` to construct parts instead of inline `tool_calls: null, tool_results: null` literals. ~30 fixture rewrites.
|
|
||||||
|
|
||||||
After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — Sam's call).
|
|
||||||
|
|
||||||
**Shipped as `v1.13.20-drop-legacy-cols` on 2026-05-23 with umbrella `v1.13` tagged on the same commit.** Slug renamed at ship time (the "v1.13.2" planning name predated the patch-monotonic-per-minor convention). Calendar wait dropped — single-user self-hosted, no production rollback constraint. Recon caught 2 additional dual-write sites beyond the roadmap's 8 (chats.ts fork-clone + extras in tool-phase.ts) and an additional fixture file (`tool_cost_stats.test.ts`) with a direct legacy-column INSERT. Adversarial review caught a `RETURNING tool_calls, tool_results` clause in the `discard_stale` endpoint that the green test suite missed — fixed by two-step UPDATE-then-SELECT-from-view so the parts-synthesized fields keep flowing on the response. Type-pruning step on `Message.tool_calls` / `Message.tool_results` skipped (the view still populates them from parts; preserving the API contract was simpler than ripping it).
|
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -510,8 +408,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
|
|||||||
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
|
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
|
||||||
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
|
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
|
||||||
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
|
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
|
||||||
- **v1.13.15-codecontext-synth (this batch, tag pending):** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
- **v1.13.15-codecontext-synth:** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
||||||
- **(column drop, pending — old working name v1.13.2):** drop `messages.tool_calls`, `messages.tool_results`; simplify `messages_with_parts` view
|
- **v1.13.16-xml-parser:** none (parser change + new `tool-suggestions.ts` helper in TS, no DB)
|
||||||
|
- **v1.13.17-cross-repo-reads:** `sessions.allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[]` (per-session cross-repo read grants)
|
||||||
|
- **v1.13.18-codecontext-file-path:** none (path resolver in `codecontext_client.ts`, no DB)
|
||||||
|
- **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:** `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-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-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
||||||
@@ -621,7 +523,17 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
|
|||||||
|
|
||||||
### v1.13.x cleanup line locked (2026-05-22)
|
### v1.13.x cleanup line locked (2026-05-22)
|
||||||
|
|
||||||
After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug` form is **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 ✅ → column drop (final, pending — old working name v1.13.2)**. **Do not fold.** Smoke isolation matters: each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches.
|
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.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
- **Patch numbers are assigned at ship time, not in planning.** The proposal/openspec folder uses a planning slug (e.g. `v1.14.x-html-artifact-panes`); the final tag uses a concrete patch monotonic-per-minor (e.g. `v1.13.19-html-artifact-panes`). Avoids the "we said v1.13.8 but actually shipped seventh" confusion that ate two retrospective passes on the roadmap.
|
||||||
|
- **Scope-revise the proposal before dispatching.** v1.13.19-html-artifact-panes flipped mid-design from "auto-bias to HTML for >100 lines" to "Markdown default, HTML on request" — the proposal got rewritten before recon. Far cheaper than discovering the wrong approach in implementation. The "brainstorm before code" discipline.
|
||||||
|
- **Recon-first dispatch finds 25–30% more sites than the roadmap inventory.** v1.13.20 recon caught 2 extra dual-write sites (chats.ts fork-clone + 2 in tool-phase.ts) and an extra fixture file. v1.13.19 recon corrected which `Pane` type to extend. Skipping recon to save a step doesn't.
|
||||||
|
- **Adversarial reviews catch what test suites miss.** v1.13.19 reviewer caught silent error-promotion in `openInPane`; v1.13.20 reviewer caught a `RETURNING tool_calls, tool_results` clause that crashes in production but slips past green tests. Both are routine code-reviewer dispatches; both saved a same-day hotfix. **Two-stage review (spec then quality) is non-negotiable when shipping fast.**
|
||||||
|
- **Calendar-gated waits are production-safety hedges that don't apply here.** v1.13.20 originally said "wait one week of production traffic on v1.13.1 before dropping columns." Sam called it out: single-user self-hosted, no rollback constraint, code-level audit + DB COUNT query is the actual safety check. Dropped the wait. Don't ritualize production-grade hedges in a single-user codebase.
|
||||||
|
|
||||||
### v1.13 retrospective (what shipped)
|
### v1.13 retrospective (what shipped)
|
||||||
|
|
||||||
@@ -634,7 +546,21 @@ After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug
|
|||||||
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
|
- **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.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.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.2 deferred** — at least one week of production traffic on v1.13.1 before dropping legacy columns. Dual-write is rollback insurance.
|
- **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.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).
|
||||||
|
- **v1.13.10-openspec** — `openspec/changes/<slug>/{proposal,tasks,design}.md` batch-doc structure adoption.
|
||||||
|
- **v1.13.11-tools** — tiered tool loading via `BOOCODE_TOOLS=core|standard|all` env (~30 LoC; was a far-future optional item, slotted in).
|
||||||
|
- **v1.13.12-ws-schemas** + **v1.13.13-ws-publish** — Zod schemas for all 27 wire-format frames, `publishFrame`/`publishUserFrame` wrappers, ~80 publish sites converted (planned as v1.13.10 / v1.13.11).
|
||||||
|
- **v1.13.14-skills-audit** — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md. Codeminer42 rules-vs-recipes framing applied.
|
||||||
|
- **v1.13.15-codecontext-synth** — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs under 32k payload budget).
|
||||||
|
- **v1.13.16-xml-parser** — Anthropic `<invoke>` parser support + Levenshtein unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names).
|
||||||
|
- **v1.13.17-cross-repo-reads** — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extraRoots; reuses the `ask_user_input` pause/resume mechanism.
|
||||||
|
- **v1.13.18-codecontext-file-path** — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` the same way `target_dir` was already resolved.
|
||||||
|
- **v1.13.19-html-artifact-panes** — pane-based artifact viewer (Markdown default + HTML on request). Scope-revised mid-design from auto-bias-HTML to Markdown-default. `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`); CSP `connect-src 'none'` + `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` defense-in-depth. Pane state is reference-only — content fetched on mount to keep jsonb small.
|
||||||
|
- **v1.13.20-drop-legacy-cols** — final strangler-fig step. 10 dual-write sites stripped (recon caught 2 beyond the original v1.13.2 inventory). `messages_with_parts` simplified to parts-only via `CREATE OR REPLACE` before column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had `RETURNING tool_calls, tool_results` — fixed via two-step UPDATE-then-SELECT-from-view. `Message` type retains the fields, populated by the view. v1.12.1 cleanup DO block removed.
|
||||||
|
- **`v1.13` umbrella** — tagged on the same commit as v1.13.20 (`211e903`). AI SDK v6 + parts-table migration complete.
|
||||||
|
|
||||||
### Pre-v1.13 architectural decisions (still load-bearing)
|
### Pre-v1.13 architectural decisions (still load-bearing)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Rules:
|
|||||||
## Refactorer
|
## Refactorer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
|
steps: 5
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
@@ -97,6 +98,7 @@ Codecontext usage:
|
|||||||
## Architect
|
## Architect
|
||||||
---
|
---
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
|
steps: 20
|
||||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
|
|||||||
9
data/mcp.json
Normal file
9
data/mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"context7": {
|
||||||
|
"type": "streamableHttp",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CODECONTEXT_URL: http://codecontext:8080
|
CODECONTEXT_URL: http://codecontext:8080
|
||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt:rw
|
- /opt:/opt:rw
|
||||||
- /home/samkintop:/home/samkintop:rw
|
- /home/samkintop:/home/samkintop:rw
|
||||||
@@ -50,6 +50,28 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- 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
|
||||||
|
|
||||||
boocode_db:
|
boocode_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: boocode_db
|
container_name: boocode_db
|
||||||
@@ -57,7 +79,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: boocode
|
POSTGRES_USER: boocode
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: boocode
|
POSTGRES_DB: boochat
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5500:5432"
|
- "127.0.0.1:5500:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# v1.14.0-outer-loop — design decisions
|
||||||
|
|
||||||
|
Answers to the dispatch's blocking questions, resolved 2026-05-23.
|
||||||
|
|
||||||
|
## D1. Step cap — what replaces MAX_TOOL_LOOP_DEPTH?
|
||||||
|
|
||||||
|
`MAX_TOOL_LOOP_DEPTH` never existed — no hard recursion depth guard was ever in the codebase. Safety came from budget (50 tool calls) + doom-loop (3 identical calls).
|
||||||
|
|
||||||
|
**Decision:** introduce `MAX_STEPS = 200` as a hard ceiling. Per-agent cap via `agent.steps` is the primary knob. Resolution: `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)`.
|
||||||
|
|
||||||
|
**Rationale:** Sam reports BooChat gets stuck at 50 tool calls (the budget) too often. The step cap should be generous — 200 is 4x the current de-facto ceiling. Budget (50 tool calls total across all steps) remains a separate concern and is not changed in this batch.
|
||||||
|
|
||||||
|
Note: "step" ≠ "tool call." One step = one stream iteration that may produce multiple parallel tool calls. Budget counts individual tool calls; step cap counts iterations. At 200 steps with average 1-2 tool calls per step, the budget (50) will fire well before the step cap in most scenarios. The step cap is a safety ceiling for cases where the model makes many 1-tool-call iterations.
|
||||||
|
|
||||||
|
## D2. step_finish — emit or not?
|
||||||
|
|
||||||
|
**Decision:** No `step_finish` part. The next `step_start` (or assistant message completion) implicitly ends the previous step.
|
||||||
|
|
||||||
|
**Rationale:** opencode only emits `step_start`. Less noise in parts, simpler code. If UI ever needs step durations, compute from the timestamps of consecutive `step_start` parts.
|
||||||
|
|
||||||
|
## D3. Step-cap hit — sentinel or quiet?
|
||||||
|
|
||||||
|
**Decision:** Write a sentinel summary on step-cap hit. Visible to the user in chat, same as budget-exhaustion's `runCapHitSummary`.
|
||||||
|
|
||||||
|
**Implementation:** Extend `runCapHitSummary` to accept a `reason: 'budget' | 'step_cap'` parameter (or add a parallel `runStepCapSummary`). The sentinel metadata kind stays `cap_hit` — frontend `CapHitSentinel` component already renders it. The sentinel's text distinguishes the two cases ("Tool budget exhausted" vs "Step limit reached").
|
||||||
|
|
||||||
|
## D4. agent.steps = 0
|
||||||
|
|
||||||
|
**Decision:** `steps: 0` means "no tool calls allowed." The loop body never executes. The assistant can only respond with text.
|
||||||
|
|
||||||
|
**Implementation:** When `effectiveCap === 0`, skip the loop entirely. Stream the first assistant turn (text-only), finalize, return. The model receives no tools in the request payload when `steps: 0` (or equivalently, tools are passed but the loop never enters the tool-execution branch).
|
||||||
|
|
||||||
|
Actually, cleaner: `steps: 0` means the loop cap is 0. The while condition `stepNumber < effectiveCap` is false on the first check. The stream phase still runs (the model produces a text response), but if it emits tool calls they're ignored and the turn finalizes as text-only. This may produce a confusing response if the model's text references tool results it never got — but `steps: 0` is an explicit constraint the agent author chose. Document in AGENTS.md parser validation.
|
||||||
|
|
||||||
|
## D5. Synthesis success terminates the loop?
|
||||||
|
|
||||||
|
**Decision:** Yes. `break` out of the loop after synthesis success. Preserves current behavior (synthesis replaces the recursive call; no further iterations).
|
||||||
|
|
||||||
|
**Rationale:** The synthesis pass produces a self-contained summary turn. Continuing the loop after synthesis would let the model issue more tool calls on top of a synthesis summary, which is semantically wrong — the synthesis IS the final answer for that tool call batch.
|
||||||
|
|
||||||
|
## D6. executeToolPhase return struct
|
||||||
|
|
||||||
|
The recursive call at `tool-phase.ts:342` is currently the last thing `executeToolPhase` does (after creating the next assistant row). After the conversion, `executeToolPhase` returns a struct the loop body reads:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ToolPhaseResult {
|
||||||
|
action: 'continue' | 'paused' | 'synthesis_done';
|
||||||
|
toolCallCount: number;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
nextAssistantId: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `continue` → loop continues; `nextAssistantId` is the new assistant message's UUID.
|
||||||
|
- `paused` → user-input or grant pause; loop breaks. `nextAssistantId` is null.
|
||||||
|
- `synthesis_done` → synthesis succeeded; loop breaks. `nextAssistantId` is null (synthesis wrote its own parts).
|
||||||
|
|
||||||
|
The loop body then:
|
||||||
|
1. Updates `toolsUsed += result.toolCallCount`
|
||||||
|
2. Appends `result.toolCalls` to `recentToolCalls`
|
||||||
|
3. Sets `assistantMessageId = result.nextAssistantId` for the next iteration
|
||||||
|
4. Increments `stepNumber`
|
||||||
|
5. Checks `result.action` — if not `continue`, breaks.
|
||||||
|
|
||||||
|
## D7. Budget vs steps interaction
|
||||||
|
|
||||||
|
Budget counts **individual tool calls** across the entire turn. Steps counts **loop iterations**. They are orthogonal:
|
||||||
|
|
||||||
|
- Budget fires when `toolsUsed >= resolveToolBudget(agent)` (currently 50 for read-only). Checked at the top of each iteration.
|
||||||
|
- Step cap fires when `stepNumber >= effectiveCap`. Checked by the loop condition.
|
||||||
|
|
||||||
|
Both produce a sentinel summary. A turn can be terminated by whichever fires first. In practice, budget (50 tool calls) fires before step cap (200 steps) unless the model produces many 0-tool-call iterations (which shouldn't happen — 0 tool calls means non-tool finish, which exits the loop via the `break` path).
|
||||||
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# v1.14.0-outer-loop — explicit outer agent loop
|
||||||
|
|
||||||
|
Replace the ad-hoc `executeToolPhase → runAssistantTurn` recursion with an explicit `while` loop. A **step** is one stream-and-tool-execute iteration; a step can contain multiple parallel tool calls. The loop terminates on non-tool finish OR step-cap hit OR doom-loop OR budget exhaustion OR abort OR synthesis success.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The current recursion works but has two problems: (a) stack depth grows linearly with tool iterations — 50 nested async frames is fragile, (b) there's no explicit step counter, so there's no per-agent step cap and no step-boundary instrumentation. BooChat also gets stuck at 50 tool calls (the budget ceiling) more often than it should — the new `MAX_STEPS = 200` hard ceiling lets the loop run much longer before the step cap fires, while the existing budget (50 tool calls) remains a separate concern.
|
||||||
|
|
||||||
|
## Recon findings (verified 2026-05-23)
|
||||||
|
|
||||||
|
- `runAssistantTurn` at `turn.ts:144-147` is the recursive entry. Returns `Promise<void>`.
|
||||||
|
- `executeToolPhase` at `tool-phase.ts:89-96` calls back into `runAssistantTurn` at `tool-phase.ts:342`.
|
||||||
|
- Recursion terminates on: non-tool finish, budget exhaustion (`args.toolsUsed >= budget`), doom-loop (3 identical calls via `detectDoomLoop`), user-input pause (ask_user_input / request_read_access), synthesis success, stream error, abort.
|
||||||
|
- **No existing hard recursion depth limit** — `MAX_TOOL_LOOP_DEPTH` does not exist. Safety comes from budget (50) + doom-loop (3 identical).
|
||||||
|
- `TurnArgs` defined in `turn.ts:127-141`, not `types.ts`. Fields: `sessionId`, `chatId`, `assistantMessageId`, `toolsUsed`, `recentToolCalls`, `signal`. All mutable fields are threaded through the recursive call.
|
||||||
|
- Synthesis pipeline (`synthesisPipeline.ts`) is a branch in `executeToolPhase` — if synthesis succeeds, recursion is skipped.
|
||||||
|
- `step_start` already in the `message_parts.kind` CHECK constraint. No schema change needed.
|
||||||
|
- `agents.ts` does NOT currently parse a `steps` field. Needs adding to `ParsedFrontmatter`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### S1. Outer loop in `turn.ts`
|
||||||
|
|
||||||
|
Convert the recursive chain to a `while (stepNumber < effectiveCap)` loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
let stepNumber = 0
|
||||||
|
while (stepNumber < effectiveCap) {
|
||||||
|
// doom-loop check
|
||||||
|
// budget check
|
||||||
|
// emit step_start part
|
||||||
|
// stream phase (executeStreamPhase)
|
||||||
|
// if no tool calls → finalize, break
|
||||||
|
// tool phase (executeToolPhase — now returns, doesn't recurse)
|
||||||
|
// if paused (user input / grant) → break
|
||||||
|
// if synthesis succeeded → break
|
||||||
|
// create next assistant message row
|
||||||
|
// increment stepNumber, update toolsUsed, append recentToolCalls
|
||||||
|
}
|
||||||
|
// if stepNumber >= effectiveCap → sentinel summary
|
||||||
|
```
|
||||||
|
|
||||||
|
`effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)` where `MAX_STEPS = 200`.
|
||||||
|
|
||||||
|
### S2. `executeToolPhase` becomes non-recursive
|
||||||
|
|
||||||
|
Remove the `runAssistantTurn` call at `tool-phase.ts:342`. Instead, return a result indicating what happened: `{action: 'continue' | 'paused' | 'synthesis_done', toolsUsed, recentToolCalls, nextAssistantId}`. The caller (the while loop) uses the action to decide whether to continue or break.
|
||||||
|
|
||||||
|
### S3. `agent.steps` field
|
||||||
|
|
||||||
|
`agents.ts:ParsedFrontmatter` gains `steps?: number`. Parser extracts it from YAML frontmatter (integer ≥ 0). `steps: 0` means "no tool calls allowed" — loop body never executes; assistant responds text-only.
|
||||||
|
|
||||||
|
### S4. Step-boundary events
|
||||||
|
|
||||||
|
At the top of each loop iteration, emit a `step_start` part with payload `{step_number, started_at}`. Uses `insertParts` into the current assistant message. No `step_finish` — the next `step_start` (or message completion) implicitly ends the previous step.
|
||||||
|
|
||||||
|
### S5. Doom-loop migration
|
||||||
|
|
||||||
|
`detectDoomLoop` check moves from `runAssistantTurn` (top of function, pre-stream) to the top of the while-loop body (same logical position). Same predicate, same threshold (3). Same `runDoomLoopSummary` call. Control flow changes from `return` (unwinding recursion) to `break` (exiting loop).
|
||||||
|
|
||||||
|
### S6. Step-cap sentinel
|
||||||
|
|
||||||
|
When `stepNumber >= effectiveCap`, write a sentinel summary like the existing `runCapHitSummary`. Reuse `runCapHitSummary` with a reason parameter distinguishing "budget exhaustion" from "step cap hit", or create a parallel `runStepCapSummary`. The sentinel makes the cap visible in chat.
|
||||||
|
|
||||||
|
### S7. AGENTS.md updates
|
||||||
|
|
||||||
|
Add `steps:` to each agent in `data/AGENTS.md`:
|
||||||
|
- Refactorer: `steps: 5`
|
||||||
|
- Architect: `steps: 20`
|
||||||
|
- All others: unset (infinity — bounded only by `MAX_STEPS = 200`)
|
||||||
|
|
||||||
|
### S8. Tests
|
||||||
|
|
||||||
|
New test file `apps/server/src/services/__tests__/outer-loop.test.ts` covering:
|
||||||
|
- Clean finish (stream returns non-tool, loop exits after 1 iteration)
|
||||||
|
- Step-cap hit (loop exits at cap, sentinel written)
|
||||||
|
- Doom-loop break (3 identical calls, sentinel written)
|
||||||
|
- Budget exhaustion (toolsUsed >= budget, cap-hit sentinel written)
|
||||||
|
- Abort mid-step (signal fires, loop exits)
|
||||||
|
- `steps: 0` edge case (no loop iterations, text-only response)
|
||||||
|
- Synthesis success (loop exits after synthesis)
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No frontend changes. `step_start` parts surface via `messages_with_parts` automatically; UI doesn't render them in v1.14.
|
||||||
|
- No `output_schema` / `exit_expression` / `execution_strategy` AGENTS.md fields.
|
||||||
|
- No per-step snapshot for revert (v2.0 BooCoder concern).
|
||||||
|
- No changes to budget constants (50 / 10 / 50). That's a separate concern.
|
||||||
|
- No `repairToolCall` changes.
|
||||||
|
- No compaction changes.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- No git commit, push. Sam commits.
|
||||||
|
- Backup before editing.
|
||||||
|
- TS strict, no `any`.
|
||||||
|
- Doom-loop threshold stays at 3.
|
||||||
|
- 332+ existing tests still pass + new outer-loop tests.
|
||||||
|
|
||||||
|
## Files expected to touch
|
||||||
|
|
||||||
|
- `apps/server/src/services/inference/turn.ts` — recursion → loop
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — remove recursive call, return result struct
|
||||||
|
- `apps/server/src/services/inference/sentinel-summaries.ts` — step-cap sentinel (or extend cap-hit)
|
||||||
|
- `apps/server/src/services/agents.ts` — parse `steps` field
|
||||||
|
- `data/AGENTS.md` — add `steps:` to Refactorer + Architect
|
||||||
|
- `apps/server/src/services/__tests__/outer-loop.test.ts` — NEW
|
||||||
|
- `apps/server/src/services/inference/index.ts` — re-export if new types needed
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
~300 LoC net (turn.ts refactor + tool-phase return struct + agents parser + tests). The conversion is structural, not behavioral — every exit path is preserved, just expressed as loop control flow instead of recursion unwinding.
|
||||||
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# v1.14.0-outer-loop tasks
|
||||||
|
|
||||||
|
## B1 — Backups
|
||||||
|
|
||||||
|
- [ ] `turn.ts`, `tool-phase.ts`, `sentinel-summaries.ts`, `agents.ts`, `data/AGENTS.md`
|
||||||
|
|
||||||
|
## B2 — agents.ts: parse `steps` field
|
||||||
|
|
||||||
|
- [ ] Add `steps?: number` to `ParsedFrontmatter` interface
|
||||||
|
- [ ] Parse from YAML frontmatter: integer ≥ 0, warn on out-of-range (negative or non-integer), clamp to 0
|
||||||
|
- [ ] Expose on the `Agent` type returned by `getAgentsForProject`
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||||
|
|
||||||
|
## B3 — AGENTS.md: add `steps:` to Refactorer + Architect
|
||||||
|
|
||||||
|
- [ ] `data/AGENTS.md` — Refactorer: `steps: 5`
|
||||||
|
- [ ] `data/AGENTS.md` — Architect: `steps: 20`
|
||||||
|
- [ ] All others: leave unset (infinite, bounded by MAX_STEPS=200)
|
||||||
|
|
||||||
|
## B4 — tool-phase.ts: remove recursive call, return result struct
|
||||||
|
|
||||||
|
- [ ] Define `ToolPhaseResult` interface: `{action: 'continue' | 'paused' | 'synthesis_done', toolCallCount: number, toolCalls: ToolCall[], nextAssistantId: string | null}`
|
||||||
|
- [ ] Remove `runAssistantTurn` import and call at line ~342
|
||||||
|
- [ ] `executeToolPhase` returns `ToolPhaseResult` instead of `Promise<void>`
|
||||||
|
- [ ] On normal path (after creating next assistant row): return `{action: 'continue', toolCallCount, toolCalls: result.toolCalls, nextAssistantId}`
|
||||||
|
- [ ] On user-input pause: return `{action: 'paused', toolCallCount: <calls executed so far>, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||||
|
- [ ] On synthesis success: return `{action: 'synthesis_done', toolCallCount, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` will FAIL here (turn.ts still expects void) — expected, fixed in B5
|
||||||
|
|
||||||
|
## B5 — turn.ts: recursion → while loop
|
||||||
|
|
||||||
|
- [ ] Add `MAX_STEPS = 200` constant
|
||||||
|
- [ ] Resolve `effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS)` at the top of `runAssistantTurn`
|
||||||
|
- [ ] Convert `runAssistantTurn` body into a `while (stepNumber < effectiveCap)` loop:
|
||||||
|
- Top of loop: doom-loop check (move from current position; `break` instead of `return`)
|
||||||
|
- Top of loop: budget check (move from current position; `break` instead of `return`, but still call `runCapHitSummary` before break)
|
||||||
|
- Emit `step_start` part via `insertParts` with payload `{step_number: stepNumber, started_at: new Date().toISOString()}`
|
||||||
|
- Call `executeStreamPhase`
|
||||||
|
- If no tool calls → `finalizeCompletion`, `break`
|
||||||
|
- Call `executeToolPhase` (now returns `ToolPhaseResult`)
|
||||||
|
- If `result.action !== 'continue'` → `break`
|
||||||
|
- Update `toolsUsed += result.toolCallCount`
|
||||||
|
- Update `recentToolCalls = [...recentToolCalls, ...result.toolCalls]`
|
||||||
|
- Update `assistantMessageId = result.nextAssistantId!`
|
||||||
|
- Increment `stepNumber`
|
||||||
|
- [ ] After loop: if `stepNumber >= effectiveCap` → call step-cap sentinel (B6)
|
||||||
|
- [ ] `effectiveCap === 0` edge case: the while condition is immediately false; stream the first turn text-only (the stream phase at the top of the function runs once before the loop — OR handle this by structuring the loop as do-while, OR handle by pre-checking and skipping tools from the request). Pick the cleanest approach.
|
||||||
|
- [ ] Remove `TurnArgs` from the module export if it's no longer threaded through recursion — OR keep it and populate from loop locals. (Design note: `TurnArgs` is still used by `executeStreamPhase`, `executeToolPhase`, `sentinel-summaries.ts`, `error-handler.ts`. Keep the interface; populate from loop locals each iteration.)
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||||
|
- [ ] `pnpm -C apps/server test` — all existing tests pass
|
||||||
|
|
||||||
|
## B6 — sentinel-summaries.ts: step-cap sentinel
|
||||||
|
|
||||||
|
- [ ] Add `runStepCapSummary` (or extend `runCapHitSummary` with a `reason` param)
|
||||||
|
- [ ] Write a sentinel with `metadata.kind = 'cap_hit'` (same as budget) so `CapHitSentinel` UI renders it
|
||||||
|
- [ ] Sentinel text distinguishes "Step limit reached (N steps)" from "Tool budget exhausted (N calls)"
|
||||||
|
- [ ] Called from the post-loop check in turn.ts (B5)
|
||||||
|
|
||||||
|
## B7 — Tests
|
||||||
|
|
||||||
|
- [ ] NEW `apps/server/src/services/__tests__/outer-loop.test.ts`
|
||||||
|
- [ ] Test: clean finish — stream returns no tool calls, loop exits after 1 step
|
||||||
|
- [ ] Test: step-cap hit — mock agent with `steps: 2`, model always returns tool calls, loop exits at 2, sentinel written
|
||||||
|
- [ ] Test: doom-loop — 3 identical tool calls, sentinel written, loop breaks
|
||||||
|
- [ ] Test: budget exhaustion — toolsUsed >= budget, cap-hit sentinel written
|
||||||
|
- [ ] Test: `steps: 0` — no loop iterations, text-only response
|
||||||
|
- [ ] Test: synthesis success — loop breaks after synthesis
|
||||||
|
- [ ] `pnpm -C apps/server test` — all 332+ existing + new tests pass
|
||||||
|
|
||||||
|
## B8 — Verification
|
||||||
|
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||||
|
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (no web changes; should pass)
|
||||||
|
- [ ] `pnpm -C apps/web build` — green
|
||||||
|
- [ ] `pnpm -C apps/server test` — all green
|
||||||
|
|
||||||
|
## B9 — Docs + tag + deploy
|
||||||
|
|
||||||
|
- [ ] `CHANGELOG.md` entry for v1.14.0-outer-loop
|
||||||
|
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.14 section
|
||||||
|
- [ ] `CLAUDE.md` updates: mention the outer loop, MAX_STEPS, agent.steps in the inference/ section
|
||||||
|
- [ ] Commit, tag `v1.14.0-outer-loop`, push, rebuild
|
||||||
39
openspec/changes/v1.14.1-mcp-poc/design.md
Normal file
39
openspec/changes/v1.14.1-mcp-poc/design.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# v1.14.1-mcp-poc — design decisions
|
||||||
|
|
||||||
|
## D1. Transport: Streamable HTTP (not stdio)
|
||||||
|
|
||||||
|
Context7 is a remote service at `https://mcp.context7.com/mcp`. Uses the MCP Streamable HTTP transport. The `@modelcontextprotocol/sdk` TypeScript client supports this via `StreamableHTTPClientTransport`. No stdio needed.
|
||||||
|
|
||||||
|
## D2. Tool name prefixing
|
||||||
|
|
||||||
|
MCP tools get a `context7_` prefix to avoid collisions with BooCode's native tools. Context7's tools are `resolve-library-id` and `query-docs` — these become `context7_resolve-library-id` and `context7_query-docs`. The prefix is stripped before calling the MCP server's `tools/call`.
|
||||||
|
|
||||||
|
## D3. Read-only invariant guard
|
||||||
|
|
||||||
|
BooChat is read-only through v1.x. The MCP client rejects any tool whose `annotations?.readOnly === false`. Tools with `readOnly: true` or no annotations are accepted. Context7's tools are all read-only (they query documentation — no write side effects). Fail-open on missing annotations is a deliberate choice: most MCP servers don't set annotations yet, and rejecting all un-annotated tools would make the feature useless. The guard catches explicitly-declared write tools.
|
||||||
|
|
||||||
|
## D4. Zod inputSchema for MCP tools
|
||||||
|
|
||||||
|
MCP tools come with a JSON Schema `inputSchema`. BooCode's `ToolDef` has both a Zod `inputSchema` (for server-side validation) and a `jsonSchema` (for the LLM's tool schema). For MCP tools:
|
||||||
|
- `jsonSchema` is built directly from the MCP tool's `inputSchema` (it's already JSON Schema).
|
||||||
|
- `inputSchema` uses `z.record(z.unknown())` as a pass-through — the MCP server does its own validation. Double-validating with a generated Zod schema from JSON Schema adds complexity with no value for a PoC.
|
||||||
|
|
||||||
|
## D5. Tool registration: append + re-sort (not lazy-init)
|
||||||
|
|
||||||
|
The simplest approach: keep `ALL_TOOLS` as the native tool array. Add an `appendMcpTools(tools: ToolDef[])` function that pushes MCP tools, re-sorts alphabetically, and rebuilds `TOOLS_BY_NAME` and `READ_ONLY_TOOL_NAMES`. Called once at startup after MCP init. More invasive approaches (lazy-init, factory function) change the import shape for every consumer. Mutation-at-startup is ugly but contained to one call site and matches the existing alpha-sort-at-module-level pattern.
|
||||||
|
|
||||||
|
## D6. No per-session toggle
|
||||||
|
|
||||||
|
Web tools have `session.web_search_enabled`. MCP tools do NOT get a session toggle in v1.14.1. If configured via env var, MCP tools are always available. Per-session MCP control is a v1.15 concern (when multiple MCP servers and the permission ruleset land together).
|
||||||
|
|
||||||
|
## D7. Graceful degradation
|
||||||
|
|
||||||
|
MCP server down at startup → log warning, expose zero MCP tools, BooCode functions normally. MCP server down mid-session (tool call fails) → the `execute` wrapper catches the error and returns `{error: true, output: "MCP server unreachable"}` — the model sees the error and can self-correct (use native tools instead).
|
||||||
|
|
||||||
|
## D8. Result content extraction
|
||||||
|
|
||||||
|
MCP `tools/call` returns `{content: ContentBlock[]}` where each block is `{type: 'text', text: string}` or `{type: 'resource', ...}`. For the PoC:
|
||||||
|
- Text blocks: join with `\n`.
|
||||||
|
- Resource blocks: serialize as JSON (the model can read structured data).
|
||||||
|
- Empty content: return `"(no output)"`.
|
||||||
|
- `isError: true` in the response: return `{error: true, output: joinedContent}`.
|
||||||
96
openspec/changes/v1.14.1-mcp-poc/proposal.md
Normal file
96
openspec/changes/v1.14.1-mcp-poc/proposal.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# v1.14.1-mcp-poc — single-server MCP client proof-of-concept
|
||||||
|
|
||||||
|
Validate the MCP-client loop end-to-end against one real MCP server (Context7) before committing to the full opencode `mcp/index.ts` port at v1.15. Small, throwaway-if-needed.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
BooCode's tool registry (`ALL_TOOLS` in `tools.ts`) is static — tools are hardcoded TypeScript modules. MCP is the protocol for dynamic tool discovery. Wiring one real MCP server end-to-end proves: tool-discovery → tool-list → tool-call → result-render → context-budget accounting all hold. If Context7 works, any MCP server will work via the same plumbing.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### S1. Install `@modelcontextprotocol/sdk`
|
||||||
|
|
||||||
|
New dependency in `apps/server/package.json`. The official TypeScript MCP client SDK (MIT). Provides `Client`, `StreamableHTTPClientTransport`, tool-call/result types.
|
||||||
|
|
||||||
|
### S2. New service: `apps/server/src/services/mcp-client.ts`
|
||||||
|
|
||||||
|
Singleton MCP client that:
|
||||||
|
1. Connects to Context7 at `MCP_CONTEXT7_URL` (default `https://mcp.context7.com/mcp`) via Streamable HTTP transport.
|
||||||
|
2. Optional `MCP_CONTEXT7_API_KEY` env var passed as a header.
|
||||||
|
3. On `initialize()`: calls `tools/list`, wraps each MCP tool as a `ToolDef`, prefixes names with `context7_` to avoid collisions with BooCode's native tools.
|
||||||
|
4. **Read-only invariant guard:** rejects any tool whose `annotations?.readOnly` is explicitly `false`. Tools with `readOnly: true` or no `annotations` field are accepted (fail-open on read-only, since most MCP tools don't set annotations yet — Context7's tools don't).
|
||||||
|
5. `callTool(name, args)` → calls the MCP server's `tools/call` endpoint and returns the result content.
|
||||||
|
6. `getTools(): ToolDef[]` → returns the discovered tools wrapped as BooCode `ToolDef` objects.
|
||||||
|
7. Graceful degradation: if the MCP server is unreachable at startup, log a warning and expose zero MCP tools. BooCode functions normally with its native tools.
|
||||||
|
|
||||||
|
### S3. Config extension
|
||||||
|
|
||||||
|
`apps/server/src/config.ts` gains two optional env vars:
|
||||||
|
- `MCP_CONTEXT7_URL` (string, default `https://mcp.context7.com/mcp`)
|
||||||
|
- `MCP_CONTEXT7_API_KEY` (string, optional)
|
||||||
|
|
||||||
|
### S4. Tool registration
|
||||||
|
|
||||||
|
`apps/server/src/services/tools.ts` — after building `ALL_TOOLS` from native tools, append MCP-discovered tools from `mcpClient.getTools()`. The alpha-sort at the end of `ALL_TOOLS` construction covers both native and MCP tools. `TOOLS_BY_NAME` map includes MCP tools.
|
||||||
|
|
||||||
|
MCP tools are registered with `category: 'read_only'` (per the read-only invariant guard in S2).
|
||||||
|
|
||||||
|
### S5. Tool dispatch
|
||||||
|
|
||||||
|
`apps/server/src/services/inference/tool-phase.ts` `executeToolCall` already dispatches via `TOOLS_BY_NAME[toolName].execute(...)`. MCP tools' `execute` function calls `mcpClient.callTool(name, args)` — the dispatch is transparent to the rest of the inference loop. No changes to `executeToolCall` needed.
|
||||||
|
|
||||||
|
### S6. MCP tool result → BooCode format
|
||||||
|
|
||||||
|
MCP `tools/call` returns `{ content: [{type: 'text', text: string}, ...] }`. BooCode's `executeToolCall` expects a string or JSON-serializable output. The `execute` wrapper in the ToolDef extracts `content[0].text` (or joins multiple content blocks with `\n`). If the MCP server returns an error, the wrapper returns `{error: true, output: errorMessage}` matching BooCode's existing error-result shape.
|
||||||
|
|
||||||
|
### S7. Startup initialization
|
||||||
|
|
||||||
|
`apps/server/src/index.ts` — after `applySchema()` and before route registration, call `mcpClient.initialize()`. If `MCP_CONTEXT7_URL` is not set (or empty), skip initialization entirely (MCP is opt-in). Log the number of discovered tools on success.
|
||||||
|
|
||||||
|
Tool registration (S4) must happen AFTER MCP initialization, since `getTools()` returns the discovered tools. Current flow: `ALL_TOOLS` is a module-level constant. This needs to change to a lazy-init pattern — either a function that returns the tool list (called once at startup after MCP init), or a mutable array that MCP tools get appended to during startup.
|
||||||
|
|
||||||
|
### S8. Agent tool whitelist interaction
|
||||||
|
|
||||||
|
MCP tools are prefixed `context7_*`. Existing agents' `tools:` whitelists don't include MCP tool names — so MCP tools are only available to the default agent (no agent selected, which gets ALL_TOOLS). To make MCP tools available to specific agents, their AGENTS.md `tools:` list would need to include `context7_*` names. For the PoC, this is fine — the default agent (most common) gets MCP tools.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No stdio transport. Context7 is HTTP-only.
|
||||||
|
- No OAuth. Context7 uses an API key header.
|
||||||
|
- No multiple servers. One hardcoded server (Context7).
|
||||||
|
- No per-agent MCP server allow/deny. All agents that don't have a `tools:` whitelist get MCP tools.
|
||||||
|
- No per-session MCP toggle. If configured, MCP tools are always available.
|
||||||
|
- No UI changes. MCP tools surface in the tool list the model sees; results render as normal tool-result parts.
|
||||||
|
- No schema changes. MCP state is in-memory only.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- No git commit/push. Sam commits.
|
||||||
|
- Read-only invariant: reject any MCP tool with `readOnly: false`.
|
||||||
|
- Graceful degradation: MCP server down → zero MCP tools, BooCode works normally.
|
||||||
|
- One new dep only: `@modelcontextprotocol/sdk`.
|
||||||
|
- Alpha-sort of ALL_TOOLS preserved (v1.13.3 prompt-cache invariant).
|
||||||
|
|
||||||
|
## Files expected to touch
|
||||||
|
|
||||||
|
- `apps/server/package.json` — add `@modelcontextprotocol/sdk`
|
||||||
|
- `pnpm-lock.yaml` — auto-updated
|
||||||
|
- `apps/server/src/config.ts` — `MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`
|
||||||
|
- `apps/server/src/services/mcp-client.ts` — NEW, ~100 lines
|
||||||
|
- `apps/server/src/services/tools.ts` — lazy-init or append MCP tools to ALL_TOOLS
|
||||||
|
- `apps/server/src/index.ts` — call `mcpClient.initialize()` at startup
|
||||||
|
- `apps/server/src/services/__tests__/mcp-client.test.ts` — NEW, unit tests for tool wrapping + read-only guard
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
~150 LoC. The MCP SDK handles the protocol; BooCode's job is wrapping discovered tools as ToolDefs and routing calls through the SDK client.
|
||||||
|
|
||||||
|
## Smoke plan
|
||||||
|
|
||||||
|
1. Set `MCP_CONTEXT7_URL=https://mcp.context7.com/mcp` in `.env` (or docker-compose env).
|
||||||
|
2. Restart boocode container.
|
||||||
|
3. Check logs: should see "mcp: initialized Context7, discovered N tools" (or similar).
|
||||||
|
4. Open a chat with no agent selected. Send "What does the `streamText` function do in the AI SDK? Use context7 to look it up."
|
||||||
|
5. Confirm: model calls `context7_resolve-library-id` then `context7_query-docs` (or whatever Context7's tool names are after prefixing).
|
||||||
|
6. Confirm: tool results render normally in the chat.
|
||||||
|
7. Without `MCP_CONTEXT7_URL` set: restart, confirm BooCode starts normally with zero MCP tools.
|
||||||
80
openspec/changes/v1.14.1-mcp-poc/tasks.md
Normal file
80
openspec/changes/v1.14.1-mcp-poc/tasks.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# v1.14.1-mcp-poc tasks
|
||||||
|
|
||||||
|
## B1 — Backups
|
||||||
|
|
||||||
|
- [ ] `apps/server/src/services/tools.ts`
|
||||||
|
- [ ] `apps/server/src/config.ts`
|
||||||
|
- [ ] `apps/server/src/index.ts`
|
||||||
|
|
||||||
|
## B2 — Install `@modelcontextprotocol/sdk`
|
||||||
|
|
||||||
|
- [ ] `pnpm -C apps/server add @modelcontextprotocol/sdk`
|
||||||
|
- [ ] Verify `pnpm -C apps/server build` still works after install
|
||||||
|
- [ ] Note the installed version
|
||||||
|
|
||||||
|
## B3 — Config extension
|
||||||
|
|
||||||
|
- [ ] `apps/server/src/config.ts` — add `MCP_CONTEXT7_URL` (string, optional, default `https://mcp.context7.com/mcp`)
|
||||||
|
- [ ] `apps/server/src/config.ts` — add `MCP_CONTEXT7_API_KEY` (string, optional)
|
||||||
|
- [ ] Both via Zod `.optional()` with `.default()` for the URL
|
||||||
|
|
||||||
|
## B4 — MCP client service
|
||||||
|
|
||||||
|
- [ ] NEW `apps/server/src/services/mcp-client.ts`
|
||||||
|
- [ ] Import `Client`, `StreamableHTTPClientTransport` from `@modelcontextprotocol/sdk/client`
|
||||||
|
- [ ] `initialize(config, log)` — connect to Context7, call `tools/list`, wrap each as ToolDef, apply read-only guard
|
||||||
|
- [ ] `callTool(name, args)` — call MCP server `tools/call`, extract text content, return as string
|
||||||
|
- [ ] `getTools()` — return wrapped ToolDef[]
|
||||||
|
- [ ] `isInitialized()` — boolean
|
||||||
|
- [ ] Read-only guard: skip tools with `annotations?.readOnly === false`; accept all others
|
||||||
|
- [ ] Graceful degradation: catch connection errors, log warning, expose zero tools
|
||||||
|
- [ ] Tool name prefixing: `context7_<original_name>`
|
||||||
|
- [ ] ToolDef wrapping: map MCP inputSchema (JSONSchema) to ToolJsonSchema `function.parameters`; use `z.any()` for Zod inputSchema (MCP already validated on the server side)
|
||||||
|
- [ ] Execute wrapper: strip `context7_` prefix before calling MCP, join result content blocks with `\n`
|
||||||
|
|
||||||
|
## B5 — Tool registration (lazy-init)
|
||||||
|
|
||||||
|
- [ ] `apps/server/src/services/tools.ts` — convert `ALL_TOOLS` from a module-level constant to a lazy-initialized array
|
||||||
|
- [ ] Add `initializeTools(mcpTools: ToolDef[])` function that builds the final sorted list
|
||||||
|
- [ ] `TOOLS_BY_NAME`, `READ_ONLY_TOOL_NAMES` derived from the initialized list
|
||||||
|
- [ ] Ensure all existing callers of `ALL_TOOLS` / `TOOLS_BY_NAME` still work (they import from tools.ts — verify the export shape)
|
||||||
|
- [ ] OR simpler: keep ALL_TOOLS as-is (native tools), add `appendMcpTools(tools)` that mutates + re-sorts + rebuilds TOOLS_BY_NAME. Less clean but less invasive.
|
||||||
|
|
||||||
|
## B6 — Startup wiring
|
||||||
|
|
||||||
|
- [ ] `apps/server/src/index.ts` — after `applySchema()`, before route registration:
|
||||||
|
- If `config.MCP_CONTEXT7_URL` is set: `await mcpClient.initialize(config, app.log)`
|
||||||
|
- `appendMcpTools(mcpClient.getTools())` (or equivalent)
|
||||||
|
- Log tool count
|
||||||
|
- [ ] If URL not set: skip, log "mcp: Context7 not configured, skipping"
|
||||||
|
|
||||||
|
## B7 — Verification
|
||||||
|
|
||||||
|
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||||
|
- [ ] `pnpm -C apps/server test` — all existing tests pass (MCP client is startup-only; tests don't initialize it)
|
||||||
|
- [ ] `pnpm -C apps/web build` — green (no web changes)
|
||||||
|
|
||||||
|
## B8 — Unit tests
|
||||||
|
|
||||||
|
- [ ] NEW `apps/server/src/services/__tests__/mcp-client.test.ts`
|
||||||
|
- [ ] Test: tool wrapping produces correct ToolDef shape (name, description, jsonSchema, execute fn)
|
||||||
|
- [ ] Test: read-only guard rejects tools with `readOnly: false`
|
||||||
|
- [ ] Test: read-only guard accepts tools with `readOnly: true` or no annotations
|
||||||
|
- [ ] Test: name prefixing — `resolve-library-id` → `context7_resolve-library-id`
|
||||||
|
- [ ] Test: result extraction — single text content block → string; multiple → joined with `\n`
|
||||||
|
- [ ] Test: error result — MCP error → `{error: true, output: ...}` shape
|
||||||
|
|
||||||
|
## B9 — Deploy + smoke
|
||||||
|
|
||||||
|
- [ ] Add `MCP_CONTEXT7_URL=https://mcp.context7.com/mcp` to docker-compose env (or .env)
|
||||||
|
- [ ] `docker compose up --build -d`
|
||||||
|
- [ ] Check logs for MCP initialization message
|
||||||
|
- [ ] Live-smoke: send a chat asking about AI SDK docs via Context7
|
||||||
|
- [ ] Verify tool calls + results render normally
|
||||||
|
|
||||||
|
## B10 — Docs + tag
|
||||||
|
|
||||||
|
- [ ] `CHANGELOG.md` entry
|
||||||
|
- [ ] `boocode_roadmap.md` retrospective bullet
|
||||||
|
- [ ] `CLAUDE.md` — mention MCP client in the tools/services section
|
||||||
|
- [ ] Commit, tag `v1.14.1-mcp-poc`, push, rebuild
|
||||||
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# v1.15.0-mcp-multi — design decisions
|
||||||
|
|
||||||
|
## D1. Config file path
|
||||||
|
|
||||||
|
`/data/mcp.json` (alongside `AGENTS.md` at `/data/AGENTS.md`). Both are bind-mounted from the host's `data/` directory. Override via `MCP_CONFIG_PATH` env var.
|
||||||
|
|
||||||
|
File missing = no MCP (opt-in by file presence, not by env var). Simpler than the v1.14.1 approach of always-defaulting a URL.
|
||||||
|
|
||||||
|
## D2. Config schema matches opencode's `mcpServers` shape
|
||||||
|
|
||||||
|
opencode uses `~/.opencode/config.json` with a `mcpServers` key. BooCode uses `mcp.json` with the same `mcpServers` key so server entries are copy-pasteable. Property names match: `type`, `url`, `command`, `args`, `env`, `headers`. BooCode adds `enabled` (boolean toggle per server, default true) which opencode doesn't have — harmless extra key.
|
||||||
|
|
||||||
|
## D3. Transport types: streamableHttp + stdio only
|
||||||
|
|
||||||
|
- **streamableHttp**: For remote servers (Context7, future cloud MCP services). Uses `@modelcontextprotocol/sdk`'s `StreamableHTTPClientTransport`.
|
||||||
|
- **stdio**: For local subprocess servers (codecontext, future local tools). Uses `@modelcontextprotocol/sdk`'s `StdioClientTransport` (spawns child process, NDJSON framing over stdin/stdout).
|
||||||
|
- **SSE**: Skipped. Streamable HTTP supersedes SSE per the MCP spec (May 2025 protocol update). If a legacy server requires SSE, it can be added later.
|
||||||
|
|
||||||
|
## D4. Tool name prefixing: `<serverName>_<toolName>`
|
||||||
|
|
||||||
|
Generalizes v1.14.1's `context7_<name>` pattern. Server name comes from the config key (e.g. `"context7"`, `"codecontext"`). Collisions between servers with the same name are impossible (config keys are unique). Collisions between an MCP tool and a native tool are possible if someone names a server entry the same as a native tool prefix — but that's a user-configuration error, not a code bug.
|
||||||
|
|
||||||
|
## D5. Per-agent glob patterns: last-match-wins
|
||||||
|
|
||||||
|
AGENTS.md `tools:` field already supports exact-match arrays. Globs extend the same field:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tools: [view_file, grep, context7_*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Evaluation: for each tool in `ALL_TOOLS`, scan the pattern list left-to-right. A `!` prefix denies. Last matching pattern wins. This matches the roadmap's "wildcard rule matcher" language.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `[*]` — all tools (same as omitting `tools:` entirely)
|
||||||
|
- `[*, !web_*]` — all tools except web
|
||||||
|
- `[view_file, grep, context7_*]` — only view_file, grep, and all Context7 tools
|
||||||
|
- `[*]` on Architect + `[view_file]` on Prompt Builder — each agent gets its intended scope
|
||||||
|
|
||||||
|
Globs use a simple `minimatch`-style check: `*` matches any characters. No `?` or `**` — tool names are flat (no path separators).
|
||||||
|
|
||||||
|
## D6. No DB tables in v1.15
|
||||||
|
|
||||||
|
The roadmap listed `permissions`, `agent_permissions`, `session_permissions`, `mcp_servers` tables. All deferred to v2.0:
|
||||||
|
|
||||||
|
- **Permission tables**: Enterprise multi-user pattern. BooChat is single-user behind Authelia. The read-only invariant guard is the BooChat-era defense. Formal permission rulesets land when BooCoder adds write tools.
|
||||||
|
- **`mcp_servers` table**: In-memory registry is sufficient. No need to persist server state to DB when the config file is the source of truth and tools are re-discovered on every boot.
|
||||||
|
|
||||||
|
## D7. Stdio child lifecycle
|
||||||
|
|
||||||
|
- Spawn on `initialize()`. Persistent connection for server lifetime (not per-call).
|
||||||
|
- On child exit (unexpected): mark server unavailable, log error. Do NOT auto-restart. BooCode continues with remaining servers.
|
||||||
|
- On BooCode shutdown (`app.addHook('onClose')`): send SIGTERM to all stdio children. Wait up to 5s, then SIGKILL.
|
||||||
|
- On ENOENT (command not found): skip server with a warning. Matches the graceful-degradation pattern from v1.14.1.
|
||||||
|
|
||||||
|
## D8. v1.14.1 env vars removed
|
||||||
|
|
||||||
|
`MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` are deleted from `config.ts`. They're superseded by the JSON config file's `context7` entry. The PoC was explicitly designed as throwaway.
|
||||||
|
|
||||||
|
Migration path for anyone who had the env vars set: add a `data/mcp.json` with the Context7 entry. The CHANGELOG entry will note this.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user