Compare commits
18 Commits
v2.4.0-uns
...
v2.5.11-cl
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d997ecb6c | |||
| dc3859975d | |||
| 23a33e893a | |||
| 8bf86ecb92 | |||
| fe52250d78 | |||
| 4035aa2b98 | |||
| 35a0aba211 | |||
| 3730dc9341 | |||
| a359a4ab8b | |||
| a8c84ecfe4 | |||
| 547fd70650 | |||
| 990a615b87 | |||
| 5352fd9942 | |||
| 66df410826 | |||
| f89c8f3f15 | |||
| cbef7618b3 | |||
| fcc7c5a86e | |||
| bcfc94fa47 |
@@ -21,6 +21,7 @@ out/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# Test artifacts / coverage
|
# Test artifacts / coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
|
|||||||
# point BooCode at a different SearXNG instance.
|
# point BooCode at a different SearXNG instance.
|
||||||
SEARXNG_URL=http://100.114.205.53:8888
|
SEARXNG_URL=http://100.114.205.53:8888
|
||||||
|
|
||||||
|
# Task model: lightweight model for auto-naming, search rewrite, etc.
|
||||||
|
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
|
||||||
|
# with FAST_MODEL when unset.
|
||||||
|
# TASK_MODEL_URL=http://100.90.172.55:7995
|
||||||
|
|
||||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||||
# sessions where the model only needs read-only filesystem access.
|
# sessions where the model only needs read-only filesystem access.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
|
!data/coder-providers.json
|
||||||
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
109
AGENTS.md
109
AGENTS.md
@@ -1,109 +0,0 @@
|
|||||||
# Agent navigation
|
|
||||||
|
|
||||||
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
|
|
||||||
|
|
||||||
Last updated: 2026-05-25
|
|
||||||
|
|
||||||
## Doc map
|
|
||||||
|
|
||||||
| Need | Read |
|
|
||||||
|------|------|
|
|
||||||
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
|
|
||||||
| Read-only chat behavior | `BOOCHAT.md` |
|
|
||||||
| Write tools, dispatch, pending changes | `BOOCODER.md` |
|
|
||||||
| Shipped vs planned, version order | `boocode_roadmap.md` |
|
|
||||||
| Latest release truth | `CHANGELOG.md` (top entry = current) |
|
|
||||||
| System diagram + data flow | `docs/ARCHITECTURE.md` |
|
|
||||||
| Current focus / blockers | `CURRENT.md` |
|
|
||||||
| Batch convention | `openspec/README.md` |
|
|
||||||
| Shipped batch snapshots | `openspec/changes/archived/` |
|
|
||||||
| Chat agent personas + tool lists | `data/AGENTS.md` |
|
|
||||||
| External repo lift inventory | `boocode_code_review.md` |
|
|
||||||
|
|
||||||
## Monorepo layout (actual)
|
|
||||||
|
|
||||||
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
|
|
||||||
|
|
||||||
| Surface | Packages | Port | Deploy |
|
|
||||||
|---------|----------|------|--------|
|
|
||||||
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
|
|
||||||
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
|
|
||||||
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
|
|
||||||
|
|
||||||
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
|
|
||||||
|
|
||||||
## Task routing
|
|
||||||
|
|
||||||
| Task type | Start here |
|
|
||||||
|-----------|------------|
|
|
||||||
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
|
|
||||||
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
|
|
||||||
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
|
|
||||||
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
|
|
||||||
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
|
|
||||||
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
|
|
||||||
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
|
|
||||||
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
|
|
||||||
|
|
||||||
## Verification (before claiming done)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm -C apps/server test && pnpm -C apps/server build
|
|
||||||
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
|
|
||||||
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
|
|
||||||
curl http://100.114.205.53:9502/api/health # BooCoder on host
|
|
||||||
```
|
|
||||||
|
|
||||||
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
|
|
||||||
|
|
||||||
## Hard rules (from CLAUDE.md)
|
|
||||||
|
|
||||||
- **Do not commit or push** unless Sam explicitly asks.
|
|
||||||
- **No app-layer auth** — Authelia at the reverse proxy.
|
|
||||||
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
|
|
||||||
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
|
|
||||||
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
|
|
||||||
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
|
|
||||||
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
|
|
||||||
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
|
|
||||||
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
|
|
||||||
|
|
||||||
## Using openspec with Cursor
|
|
||||||
|
|
||||||
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
|
|
||||||
|
|
||||||
### When starting a batch
|
|
||||||
|
|
||||||
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
|
|
||||||
2. Write `proposal.md` — why, scope, non-goals, dependencies.
|
|
||||||
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
|
|
||||||
4. Optional `design.md` — schema/API decisions that outlive the batch.
|
|
||||||
|
|
||||||
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
|
|
||||||
|
|
||||||
### Prompting an agent
|
|
||||||
|
|
||||||
```
|
|
||||||
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
|
|
||||||
Implement tasks 1–3. Server tests must pass. Do not commit.
|
|
||||||
```
|
|
||||||
|
|
||||||
Attach the spec files with `@` so they load into context. Point at specific code paths when known:
|
|
||||||
|
|
||||||
```
|
|
||||||
@openspec/changes/v2-x/proposal.md
|
|
||||||
Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns.
|
|
||||||
```
|
|
||||||
|
|
||||||
### After shipping
|
|
||||||
|
|
||||||
- Tag: `vMAJOR.MINOR.PATCH-slug`
|
|
||||||
- Add entry to top of `CHANGELOG.md`
|
|
||||||
- Move or snapshot the openspec folder to `archived/` if you want history preserved
|
|
||||||
- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked
|
|
||||||
|
|
||||||
### What not to use openspec for
|
|
||||||
|
|
||||||
- One-line bug fixes — just describe the bug + file.
|
|
||||||
- Exploratory questions — Ask mode + `@CLAUDE.md` is enough.
|
|
||||||
- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions.
|
|
||||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.5.11-claude-skill-discovery — 2026-05-29
|
||||||
|
|
||||||
|
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
||||||
|
|
||||||
|
## v2.5.10-opencode-live-commands — 2026-05-29
|
||||||
|
|
||||||
|
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
|
||||||
|
|
||||||
|
## v2.5.9-agent-slash-commands — 2026-05-29
|
||||||
|
|
||||||
|
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
|
||||||
|
|
||||||
|
## v2.5.8-mobile-composer-row — 2026-05-29
|
||||||
|
|
||||||
|
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
|
||||||
|
|
||||||
|
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
|
||||||
|
|
||||||
|
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||||
|
|
||||||
|
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
|
||||||
|
|
||||||
|
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 5–30s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
|
||||||
|
|
||||||
|
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
|
||||||
|
|
||||||
|
## v2.5.3-remove-cursor-copilot — 2026-05-29
|
||||||
|
|
||||||
|
Retire the cursor and copilot providers from BooCoder entirely. Removes their `acp-spawn` argv cases, `provider-manifest` mode blocks + manifest keys, `provider-commands` command maps, the `provider-snapshot` cursor model-CLI branch (and the now-orphaned `exec`/`promisify` imports), and the `agent-probe` copilot ACP-detect branch; deletes the dead `cursor-models.ts` module and its test. The `PROVIDERS` registry array already lacked both entries, so only the doc comment needed correcting. Built-ins unchanged: claude, opencode, goose, qwen, native boocode. Standalone cleanup; pairs with `v2.5.4-provider-lifecycle-phase1` which builds on it.
|
||||||
|
|
||||||
|
## v2.5.2-coder-ux-fixes — 2026-05-29
|
||||||
|
|
||||||
|
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3–v2.5.1 entries were never backfilled and remain absent above.)
|
||||||
|
|
||||||
## v2.2.2-xml-placeholder-reject — 2026-05-26
|
## v2.2.2-xml-placeholder-reject — 2026-05-26
|
||||||
|
|
||||||
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
|
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -147,8 +147,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||||
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
||||||
|
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
|
||||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||||
@@ -157,8 +158,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
||||||
- `/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` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
|
||||||
- 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`.
|
- 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 requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
@@ -185,3 +186,11 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||||
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
||||||
|
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
|
||||||
|
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
|
||||||
|
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||||
|
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||||
|
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||||
|
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||||
|
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||||
|
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ GITEA_USER=indifferentketchup
|
|||||||
GITEA_SSH_HOST=100.114.205.53:2222
|
GITEA_SSH_HOST=100.114.205.53:2222
|
||||||
MCP_CONFIG_PATH=/data/mcp.json
|
MCP_CONFIG_PATH=/data/mcp.json
|
||||||
SKILLS_ROOT=/opt/boocode/data/skills
|
SKILLS_ROOT=/opt/boocode/data/skills
|
||||||
|
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ const ConfigSchema = z.object({
|
|||||||
GITEA_TOKEN: z.string().optional(),
|
GITEA_TOKEN: z.string().optional(),
|
||||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||||
MCP_CONFIG_PATH: z.string().optional(),
|
MCP_CONFIG_PATH: z.string().optional(),
|
||||||
|
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
||||||
|
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
||||||
|
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
|
||||||
|
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
|
||||||
|
// probed more recently than this. 24h default — stale model lists self-heal
|
||||||
|
// on the next snapshot; an explicit /refresh always re-probes.
|
||||||
|
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
|
||||||
// v2.0.5: cheaper model for titles, summaries, labeling.
|
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||||
FAST_MODEL: z.string().optional(),
|
FAST_MODEL: z.string().optional(),
|
||||||
// SSH access to the host for external agent dispatch (Phase 5)
|
// SSH access to the host for external agent dispatch (Phase 5)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import {
|
import {
|
||||||
listPending,
|
listPending,
|
||||||
@@ -6,7 +7,14 @@ import {
|
|||||||
applyAll,
|
applyAll,
|
||||||
rejectOne,
|
rejectOne,
|
||||||
rewindOne,
|
rewindOne,
|
||||||
|
queueCreate,
|
||||||
} from '../services/pending_changes.js';
|
} from '../services/pending_changes.js';
|
||||||
|
import { WriteGuardError } from '../services/write_guard.js';
|
||||||
|
|
||||||
|
const CreateBody = z.object({
|
||||||
|
file_path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve project root from a session's project path.
|
* Resolve project root from a session's project path.
|
||||||
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
|
||||||
|
// (manual create from the RightRail file browser; no inference involved).
|
||||||
|
// queueCreate runs resolveWritePath internally, so a path that escapes the
|
||||||
|
// project root or hits a secret file throws WriteGuardError → 422 with the
|
||||||
|
// guard message. Mirrors the { error } 404 shape used by the other routes
|
||||||
|
// and the 422 status used by apply/rewind on failure.
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/pending/create',
|
||||||
|
async (req, reply) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
|
||||||
|
const parsed = CreateBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||||
|
if (!projectRoot) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session or project not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const change = await queueCreate(
|
||||||
|
sql,
|
||||||
|
sessionId,
|
||||||
|
null,
|
||||||
|
parsed.data.file_path,
|
||||||
|
parsed.data.content,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
return change;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof WriteGuardError) {
|
||||||
|
reply.code(422);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/pending/apply',
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
|
|||||||
pane_id: z.string().min(1).max(200),
|
pane_id: z.string().min(1).max(200),
|
||||||
skill_name: z.string().min(1),
|
skill_name: z.string().min(1),
|
||||||
user_message: z.string().max(64_000).nullable().optional(),
|
user_message: z.string().max(64_000).nullable().optional(),
|
||||||
|
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
|
||||||
|
// its body is injected into a dispatched task instead of native inference.
|
||||||
|
provider: z.string().max(100).optional(),
|
||||||
|
model: z.string().max(200).optional(),
|
||||||
|
mode_id: z.string().max(200).optional(),
|
||||||
|
thinking_option_id: z.string().max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface InferenceApi {
|
interface InferenceApi {
|
||||||
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const { pane_id, skill_name } = parsed.data;
|
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
||||||
const sessionRows = await sql<{ id: string }[]>`
|
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) {
|
if (sessionRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
|
|||||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
|
||||||
|
// stays server-side (like the native path's tool message) and is injected
|
||||||
|
// into a dispatched task; the agent receives the skill instructions + the
|
||||||
|
// user's text. Mirrors the messages-route external-provider dispatch.
|
||||||
|
if (provider && provider !== 'boocode') {
|
||||||
|
const [userMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
|
||||||
|
|
||||||
|
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||||
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||||
|
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||||
|
RETURNING id, state
|
||||||
|
`;
|
||||||
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
reply.code(202);
|
||||||
|
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||||
|
}
|
||||||
|
|
||||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
|
|||||||
@@ -66,8 +66,31 @@ CREATE OR REPLACE VIEW human_inbox AS
|
|||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||||
|
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
|
||||||
|
-- an agent's live command set survives the tier-2 probe skip and shows without a
|
||||||
|
-- dispatch.
|
||||||
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
-- v2.2.0: Paseo-style session config on tasks.
|
-- v2.2.0: Paseo-style session config on tasks.
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||||
|
|
||||||
|
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
||||||
|
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
|
||||||
|
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||||
|
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
|
||||||
|
-- always sees the committed row. A trigger covers all insert paths with no
|
||||||
|
-- app-code drift. Idempotent: re-applied on every startup.
|
||||||
|
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('tasks_new', '');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
|
||||||
|
CREATE TRIGGER tasks_notify_new
|
||||||
|
AFTER INSERT ON tasks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_tasks_new();
|
||||||
|
|||||||
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
|
||||||
|
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||||
|
import type { CoderProvidersFile } from '../provider-config.js';
|
||||||
|
import { PROVIDERS } from '../provider-registry.js';
|
||||||
|
|
||||||
|
/** Resolved def for a provider id under the given config (default: no override). */
|
||||||
|
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
|
||||||
|
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
|
||||||
|
if (!def) throw new Error(`no resolved def for ${name}`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveLaunchSpec', () => {
|
||||||
|
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
|
||||||
|
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
|
||||||
|
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
|
||||||
|
// parity here is dispatch parity.
|
||||||
|
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
|
||||||
|
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
|
||||||
|
expect(spec!.binary).toBe('/usr/bin/opencode');
|
||||||
|
expect(spec!.env).toBeUndefined();
|
||||||
|
// cross-check against the switch source-of-truth
|
||||||
|
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
|
||||||
|
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
|
||||||
|
const spec = resolveLaunchSpec(builtin('opencode'), null);
|
||||||
|
expect(spec!.binary).toBe('opencode');
|
||||||
|
expect(spec!.args).toEqual(['acp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
|
||||||
|
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- config-driven launch (the new capability) ------------------------------
|
||||||
|
it('custom ACP entry → configured command + env reach the spec', () => {
|
||||||
|
const def = builtin('amp-acp', {
|
||||||
|
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
|
||||||
|
});
|
||||||
|
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
|
||||||
|
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
|
||||||
|
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('built-in WITH a config command override uses the override, not the switch default', () => {
|
||||||
|
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
|
||||||
|
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
|
||||||
|
expect(spec!.binary).toBe('opencode');
|
||||||
|
expect(spec!.args).toEqual(['acp', '--verbose']);
|
||||||
|
expect(spec!.env).toEqual({ DEBUG: '1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
|
||||||
|
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
|
||||||
|
// The env merge layers config env over process.env; for a built-in with no
|
||||||
|
// config env, spec.env is undefined → { ...process.env } (byte-identical).
|
||||||
|
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
|
||||||
|
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { parseCursorAgentModelsOutput } from '../cursor-models.js';
|
|
||||||
|
|
||||||
describe('parseCursorAgentModelsOutput', () => {
|
|
||||||
it('parses cursor-agent models output with default marker', () => {
|
|
||||||
const output = `
|
|
||||||
Available models
|
|
||||||
claude-4-sonnet - Claude 4 Sonnet (default)
|
|
||||||
gpt-4.1 - GPT-4.1
|
|
||||||
Tip: use cursor-agent models for full list
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const models = parseCursorAgentModelsOutput(output);
|
|
||||||
|
|
||||||
expect(models).toEqual([
|
|
||||||
{ id: 'claude-4-sonnet', label: 'Claude 4 Sonnet', isDefault: true },
|
|
||||||
{ id: 'gpt-4.1', label: 'GPT-4.1', isDefault: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses current marker when no default', () => {
|
|
||||||
const output = `
|
|
||||||
model-a - Model A (current)
|
|
||||||
model-b - Model B
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const models = parseCursorAgentModelsOutput(output);
|
|
||||||
|
|
||||||
expect(models.find((m) => m.id === 'model-a')?.isDefault).toBe(true);
|
|
||||||
expect(models.find((m) => m.id === 'model-b')?.isDefault).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to first model when no markers', () => {
|
|
||||||
const output = 'alpha - Alpha\nbeta - Beta';
|
|
||||||
const models = parseCursorAgentModelsOutput(output);
|
|
||||||
|
|
||||||
expect(models[0]?.isDefault).toBe(true);
|
|
||||||
expect(models[1]?.isDefault).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips malformed lines', () => {
|
|
||||||
const output = 'no-separator\nvalid - Valid';
|
|
||||||
const models = parseCursorAgentModelsOutput(output);
|
|
||||||
|
|
||||||
expect(models).toEqual([{ id: 'valid', label: 'Valid', isDefault: true }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provid
|
|||||||
|
|
||||||
describe('provider-commands', () => {
|
describe('provider-commands', () => {
|
||||||
it('defines commands for every external harness', () => {
|
it('defines commands for every external harness', () => {
|
||||||
for (const name of ['claude', 'opencode', 'cursor', 'goose', 'qwen', 'copilot']) {
|
for (const name of ['claude', 'opencode', 'goose', 'qwen']) {
|
||||||
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
|
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||||
|
import { PROVIDERS } from '../provider-registry.js';
|
||||||
|
import type { CoderProvidersFile } from '../provider-config.js';
|
||||||
|
|
||||||
|
describe('buildResolvedRegistry', () => {
|
||||||
|
it('applies a built-in override (goose label)', () => {
|
||||||
|
const config: CoderProvidersFile = { providers: { goose: { label: 'Goosey' } } };
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||||
|
const goose = reg.get('goose');
|
||||||
|
expect(goose).toBeDefined();
|
||||||
|
expect(goose!.label).toBe('Goosey');
|
||||||
|
expect(goose!.configLabel).toBe('Goosey');
|
||||||
|
expect(goose!.enabled).toBe(true);
|
||||||
|
expect(goose!.isBuiltin).toBe(true);
|
||||||
|
expect(goose!.isCustomAcp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a custom ACP entry (extends:acp + label + command)', () => {
|
||||||
|
const config: CoderProvidersFile = {
|
||||||
|
providers: {
|
||||||
|
'amp-acp': { extends: 'acp', label: 'Amp', description: 'ACP wrapper', command: ['amp-acp', '--acp'], env: { AMP: '1' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||||
|
const amp = reg.get('amp-acp');
|
||||||
|
expect(amp).toBeDefined();
|
||||||
|
expect(amp!.isCustomAcp).toBe(true);
|
||||||
|
expect(amp!.isBuiltin).toBe(false);
|
||||||
|
expect(amp!.transport).toBe('acp');
|
||||||
|
expect(amp!.modelSource).toBe('probe');
|
||||||
|
expect(amp!.launchCommand).toEqual(['amp-acp', '--acp']);
|
||||||
|
expect(amp!.env).toEqual({ AMP: '1' });
|
||||||
|
expect(amp!.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a disabled built-in in the registry flagged disabled (goose)', () => {
|
||||||
|
const config: CoderProvidersFile = { providers: { goose: { enabled: false } } };
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||||
|
expect(reg.has('goose')).toBe(true);
|
||||||
|
expect(reg.get('goose')!.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a custom id without extends (no throw)', () => {
|
||||||
|
const config: CoderProvidersFile = { providers: { weird: { label: 'Weird', command: ['weird'] } } };
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||||
|
expect(reg.has('weird')).toBe(false);
|
||||||
|
// built-ins untouched
|
||||||
|
expect(reg.size).toBe(PROVIDERS.length);
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores enabled:false on boocode and warns', () => {
|
||||||
|
const config: CoderProvidersFile = { providers: { boocode: { enabled: false } } };
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||||
|
expect(reg.get('boocode')!.enabled).toBe(true);
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries config models + additionalModels onto built-in and custom defs', () => {
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, {
|
||||||
|
providers: {
|
||||||
|
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
|
||||||
|
'amp-acp': {
|
||||||
|
extends: 'acp',
|
||||||
|
label: 'Amp',
|
||||||
|
command: ['amp-acp'],
|
||||||
|
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
|
||||||
|
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
|
||||||
|
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||||
|
expect(reg.size).toBe(PROVIDERS.length);
|
||||||
|
expect([...reg.keys()]).toEqual(PROVIDERS.map((p) => p.name));
|
||||||
|
for (const def of PROVIDERS) {
|
||||||
|
const r = reg.get(def.name)!;
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.isBuiltin).toBe(true);
|
||||||
|
expect(r.isCustomAcp).toBe(false);
|
||||||
|
expect(r.launchCommand).toBeNull();
|
||||||
|
expect(r.label).toBe(def.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
mergeModels,
|
mergeModels,
|
||||||
prefixLlamaSwapModels,
|
prefixLlamaSwapModels,
|
||||||
clearProviderSnapshotCache,
|
clearProviderSnapshotCache,
|
||||||
getProviderSnapshot,
|
getProviderSnapshot,
|
||||||
} from '../provider-snapshot.js';
|
} from '../provider-snapshot.js';
|
||||||
|
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||||
|
|
||||||
vi.mock('../acp-probe.js', () => ({
|
vi.mock('../acp-probe.js', () => ({
|
||||||
probeAcpProvider: vi.fn(),
|
probeAcpProvider: vi.fn(),
|
||||||
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
|
|||||||
|
|
||||||
const mockProbe = vi.mocked(probeAcpProvider);
|
const mockProbe = vi.mocked(probeAcpProvider);
|
||||||
|
|
||||||
|
/** Write a temp coder-providers.json and point the resolved registry at it. */
|
||||||
|
function loadConfigFixture(providers: Record<string, unknown>): void {
|
||||||
|
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
|
||||||
|
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
|
||||||
|
loadProviderConfig(path);
|
||||||
|
}
|
||||||
|
|
||||||
function mockSql(agents: Array<{
|
function mockSql(agents: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
install_path: string | null;
|
install_path: string | null;
|
||||||
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
|
|||||||
models: Array<{ id: string; label: string }> | null;
|
models: Array<{ id: string; label: string }> | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
|
last_probed_at?: string | null;
|
||||||
}>) {
|
}>) {
|
||||||
return vi.fn((strings: TemplateStringsArray) => {
|
return vi.fn((strings: TemplateStringsArray) => {
|
||||||
const query = strings.join('');
|
const query = strings.join('');
|
||||||
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||||
|
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||||
} as import('../config.js').Config;
|
} as import('../config.js').Config;
|
||||||
|
|
||||||
describe('prefixLlamaSwapModels', () => {
|
describe('prefixLlamaSwapModels', () => {
|
||||||
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
|
|||||||
describe('getProviderSnapshot', () => {
|
describe('getProviderSnapshot', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearProviderSnapshotCache();
|
clearProviderSnapshotCache();
|
||||||
|
// Reset the resolved registry to built-ins-only (missing path → {} config).
|
||||||
|
loadProviderConfig('/nonexistent-coder-providers.json');
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
@@ -165,4 +180,178 @@ describe('getProviderSnapshot', () => {
|
|||||||
expect(claude?.modes.length).toBeGreaterThan(0);
|
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||||
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
|
||||||
|
loadConfigFixture({ goose: { enabled: false } });
|
||||||
|
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'g1', label: 'G1' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const goose = entries.find((e) => e.name === 'goose');
|
||||||
|
|
||||||
|
expect(goose?.status).toBe('unavailable');
|
||||||
|
expect(goose?.enabled).toBe(false);
|
||||||
|
expect(goose?.installed).toBe(false);
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||||
|
|
||||||
|
const sql = mockSql([]); // nothing probed/installed
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const opencode = entries.find((e) => e.name === 'opencode');
|
||||||
|
|
||||||
|
expect(opencode?.status).toBe('unavailable');
|
||||||
|
expect(opencode?.enabled).toBe(true);
|
||||||
|
expect(opencode?.installed).toBe(false);
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
// If this were wrongly called, cached-goose would be replaced and the
|
||||||
|
// not.toHaveBeenCalled assertion would fail.
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(), // fresh
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// force=false → cache-miss returns loading; second call joins the build / cache.
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||||
|
const goose = entries.find((e) => e.name === 'goose');
|
||||||
|
|
||||||
|
expect(goose?.status).toBe('ready');
|
||||||
|
expect(goose?.installed).toBe(true);
|
||||||
|
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
|
||||||
|
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
|
||||||
|
expect(mockProbe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'fresh-probe', label: 'Fresh' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: [{ id: 'cached-goose', label: 'Cached' }],
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: new Date().toISOString(), // fresh, but force overrides
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||||
|
expect(mockProbe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('native boocode → ready, enabled, installed', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
const sql = mockSql([]);
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const boocode = entries.find((e) => e.name === 'boocode');
|
||||||
|
|
||||||
|
expect(boocode?.status).toBe('ready');
|
||||||
|
expect(boocode?.enabled).toBe(true);
|
||||||
|
expect(boocode?.installed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
|
||||||
|
loadConfigFixture({
|
||||||
|
claude: {
|
||||||
|
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
|
||||||
|
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'claude',
|
||||||
|
install_path: '/usr/bin/claude',
|
||||||
|
supports_acp: false,
|
||||||
|
models: [{ id: 'old-static', label: 'Old' }],
|
||||||
|
label: 'Claude Code',
|
||||||
|
transport: 'pty',
|
||||||
|
last_probed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||||
|
const claude = entries.find((e) => e.name === 'claude');
|
||||||
|
const ids = claude!.models.map((m) => m.id);
|
||||||
|
|
||||||
|
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
|
||||||
|
expect(ids).toContain('sonnet'); // additionalModels merged on top
|
||||||
|
expect(ids).not.toContain('old-static'); // replaced, not appended
|
||||||
|
// thinking options still attach to the config-provided models
|
||||||
|
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||||
|
loadConfigFixture({});
|
||||||
|
mockProbe.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
models: [{ id: 'm1', label: 'M1' }],
|
||||||
|
modes: [],
|
||||||
|
defaultModeId: null,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sql = mockSql([
|
||||||
|
{
|
||||||
|
name: 'goose',
|
||||||
|
install_path: '/usr/bin/goose',
|
||||||
|
supports_acp: true,
|
||||||
|
models: null,
|
||||||
|
label: 'Goose',
|
||||||
|
transport: 'acp',
|
||||||
|
last_probed_at: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
|
||||||
|
const probeCallsAfterFirst = mockProbe.mock.calls.length;
|
||||||
|
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
|
||||||
|
const probeCallsAfterSecond = mockProbe.mock.calls.length;
|
||||||
|
|
||||||
|
// Success criterion: second snapshot is served from cache with no ACP spawns.
|
||||||
|
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parity guard between the two copies of the provider snapshot types:
|
||||||
|
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||||
|
* apps/web/src/api/types.ts (web wire copy)
|
||||||
|
*
|
||||||
|
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||||
|
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||||
|
* assignability check was attempted first (a web-side file importing coder's
|
||||||
|
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||||
|
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||||
|
* import is structurally blocked. This runtime guard FAILS on any field
|
||||||
|
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||||
|
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||||
|
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||||
|
*/
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||||
|
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||||
|
|
||||||
|
function extractBlock(src: string, name: string): string {
|
||||||
|
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||||
|
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||||
|
const block = iface?.[0] ?? alias?.[0];
|
||||||
|
if (!block) throw new Error(`type block '${name}' not found`);
|
||||||
|
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||||
|
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||||
|
return block
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
l.length > 0 &&
|
||||||
|
!l.startsWith('//') &&
|
||||||
|
!l.startsWith('/*') &&
|
||||||
|
!l.startsWith('*'),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||||
|
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||||
|
// drift anywhere in the snapshot surface is caught.
|
||||||
|
const names = [
|
||||||
|
'ProviderSnapshotStatus',
|
||||||
|
'ProviderSnapshotEntry',
|
||||||
|
'ProviderModel',
|
||||||
|
'ProviderMode',
|
||||||
|
'ThinkingOption',
|
||||||
|
'AgentCommand',
|
||||||
|
];
|
||||||
|
for (const name of names) {
|
||||||
|
it(`${name} is identical in both copies`, () => {
|
||||||
|
expect(
|
||||||
|
extractBlock(webSrc, name),
|
||||||
|
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||||
|
).toBe(extractBlock(coderSrc, name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -26,7 +26,8 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
import { resolveLaunchSpec } from './acp-spawn.js';
|
||||||
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||||
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
broker?: Broker;
|
broker?: Broker;
|
||||||
installPath?: string;
|
installPath?: string;
|
||||||
|
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
|
||||||
|
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
|
||||||
|
resolved?: ResolvedProviderDef;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
log: FastifyBaseLogger;
|
log: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
|||||||
broker,
|
broker,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const args = resolveAcpSpawnArgs(agent);
|
// v2.3 phase 3: launch from the resolved registry def (config override /
|
||||||
if (!args) {
|
// custom-ACP command) with the built-in switch as the fallback. The dispatcher
|
||||||
|
// passes `resolved`; fall back to a registry lookup if it didn't.
|
||||||
|
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
|
||||||
|
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
|
||||||
|
if (!spec) {
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
output: `Agent '${agent}' does not support ACP.`,
|
output: `Agent '${agent}' does not support ACP.`,
|
||||||
@@ -293,12 +301,11 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const binary = installPath ?? agent;
|
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||||
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
const child = spawn(spec.binary, spec.args, {
|
||||||
const child = spawn(binary, args, {
|
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env, ...spec.env },
|
||||||
});
|
});
|
||||||
|
|
||||||
const streamCtx = new AcpStreamContext(
|
const streamCtx = new AcpStreamContext(
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export async function probeAcpProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||||
|
// available_commands_update is an async session notification opencode sends
|
||||||
|
// shortly AFTER newSession resolves — reading probedCommands synchronously
|
||||||
|
// here races it and captures nothing. Wait briefly for the first batch, then
|
||||||
|
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
|
||||||
|
const deadline = Date.now() + 3_000;
|
||||||
|
while (probedCommands.length === 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
}
|
||||||
|
if (probedCommands.length > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
const result = parseSessionResponse(session, agent);
|
const result = parseSessionResponse(session, agent);
|
||||||
result.commands = probedCommands;
|
result.commands = probedCommands;
|
||||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
|
* Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
|
||||||
|
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
|
||||||
|
* does NOT replace them.
|
||||||
*/
|
*/
|
||||||
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||||
switch (agent) {
|
switch (agent) {
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
case 'goose':
|
case 'goose':
|
||||||
return ['acp'];
|
return ['acp'];
|
||||||
case 'cursor':
|
|
||||||
return ['acp'];
|
|
||||||
case 'copilot':
|
|
||||||
return ['--acp'];
|
|
||||||
case 'qwen':
|
case 'qwen':
|
||||||
return ['--acp'];
|
return ['--acp'];
|
||||||
default:
|
default:
|
||||||
@@ -17,13 +17,34 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
/**
|
||||||
switch (agent) {
|
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
|
||||||
case 'cursor':
|
* Consults the resolved registry's launchCommand (config override or custom-ACP
|
||||||
return ['cursor-agent', 'agent'];
|
* entry) first; otherwise falls back to the built-in default argv above.
|
||||||
case 'copilot':
|
*
|
||||||
return ['copilot'];
|
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
|
||||||
default:
|
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
|
||||||
return [agent];
|
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
|
||||||
|
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
|
||||||
|
* the old path spawned the bare agent name when install_path was missing, so we
|
||||||
|
* preserve the `?? id` fallback rather than fail.)
|
||||||
|
*/
|
||||||
|
export function resolveLaunchSpec(
|
||||||
|
resolved: ResolvedProviderDef,
|
||||||
|
installPath: string | null,
|
||||||
|
): { binary: string; args: string[]; env?: Record<string, string> } | null {
|
||||||
|
if (resolved.launchCommand) {
|
||||||
|
return {
|
||||||
|
binary: resolved.launchCommand[0],
|
||||||
|
args: resolved.launchCommand.slice(1),
|
||||||
|
env: resolved.env,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const args = resolveAcpSpawnArgs(resolved.id);
|
||||||
|
if (!args) return null;
|
||||||
|
return { binary: installPath ?? resolved.id, args, env: resolved.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||||
|
return [agent];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import { exec as execCb } from 'node:child_process';
|
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
|
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||||
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||||
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
|
import { loadConfig } from '../config.js';
|
||||||
|
import { loadProviderConfig } from './provider-config-registry.js';
|
||||||
|
|
||||||
const exec = promisify(execCb);
|
const exec = promisify(execCb);
|
||||||
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
|
// `which` via execFile (no shell) — the binary name can come from the config
|
||||||
|
// file (custom ACP entries), so avoid interpolating it into a shell string.
|
||||||
|
async function whichBinary(bin: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
|
||||||
|
const path = stdout.trim();
|
||||||
|
return path || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
||||||
const candidates = resolveAcpProbeBinaries(agentName);
|
const candidates = resolveAcpProbeBinaries(agentName);
|
||||||
for (const bin of candidates) {
|
for (const bin of candidates) {
|
||||||
try {
|
const path = await whichBinary(bin);
|
||||||
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
|
if (path) return path;
|
||||||
const path = stdout.trim();
|
|
||||||
if (path) return path;
|
|
||||||
} catch {
|
|
||||||
/* try next */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -27,15 +37,6 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
|
|||||||
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
||||||
if (transport !== 'acp') return false;
|
if (transport !== 'acp') return false;
|
||||||
|
|
||||||
if (agentName === 'copilot') {
|
|
||||||
try {
|
|
||||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
|
||||||
return stdout.includes('--acp');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentName === 'qwen') {
|
if (agentName === 'qwen') {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||||
@@ -55,14 +56,37 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe for available agents on the HOST.
|
* Probe for available agents on the HOST.
|
||||||
|
*
|
||||||
|
* v2.3: iterates the resolved provider registry (built-ins + config-backed
|
||||||
|
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
|
||||||
|
* boocode is not probed; disabled providers are skipped (their `available_agents`
|
||||||
|
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
|
||||||
|
* no DB column in Phase 1 (design.md §3.3).
|
||||||
*/
|
*/
|
||||||
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||||
clearProviderSnapshotCache();
|
clearProviderSnapshotCache();
|
||||||
log.info('agent-probe: scanning for known agents');
|
log.info('agent-probe: scanning for known agents');
|
||||||
|
|
||||||
for (const agentName of PROBED_AGENT_NAMES) {
|
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
|
||||||
|
|
||||||
|
for (const resolved of registry.values()) {
|
||||||
|
const agentName = resolved.id;
|
||||||
|
|
||||||
|
// Native boocode is not a probed host agent.
|
||||||
|
if (resolved.transport === 'native') continue;
|
||||||
|
|
||||||
|
// Disabled providers: skip the probe, keep any existing row.
|
||||||
|
if (!resolved.enabled) {
|
||||||
|
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installPath = await resolveInstallPath(agentName);
|
// Custom ACP entries resolve their binary from command[0]; built-ins use
|
||||||
|
// the per-agent probe binaries.
|
||||||
|
const installPath = resolved.isCustomAcp && resolved.launchCommand
|
||||||
|
? await whichBinary(resolved.launchCommand[0])
|
||||||
|
: await resolveInstallPath(agentName);
|
||||||
if (!installPath) continue;
|
if (!installPath) continue;
|
||||||
|
|
||||||
let version: string | null = null;
|
let version: string | null = null;
|
||||||
@@ -73,24 +97,34 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
|
|||||||
/* optional */
|
/* optional */
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
// Custom ACP entries are ACP by declaration; built-ins detect support.
|
||||||
let supportsAcp = providerDef?.transport === 'acp';
|
let supportsAcp: boolean;
|
||||||
if (supportsAcp) {
|
if (resolved.isCustomAcp) {
|
||||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
supportsAcp = true;
|
||||||
|
} else {
|
||||||
|
supportsAcp = resolved.transport === 'acp';
|
||||||
|
if (supportsAcp) {
|
||||||
|
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let models: Array<{ id: string; label: string }> = [];
|
let models: Array<{ id: string; label: string }> = [];
|
||||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
if (!resolved.isCustomAcp) {
|
||||||
models = providerDef.staticModels;
|
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||||
|
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||||
|
models = providerDef.staticModels;
|
||||||
|
}
|
||||||
|
if (agentName === 'qwen') {
|
||||||
|
models = await readQwenSettingsModels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentName === 'qwen') {
|
const label = resolved.configLabel ?? resolved.label;
|
||||||
models = await readQwenSettingsModels();
|
const transport = resolved.isCustomAcp
|
||||||
}
|
? 'acp'
|
||||||
|
: resolved.transport === 'acp' && !supportsAcp
|
||||||
const label = providerDef?.label ?? agentName;
|
? 'pty'
|
||||||
const transport =
|
: (resolved.transport ?? 'pty');
|
||||||
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
|
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||||
|
|||||||
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
|
||||||
|
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
|
||||||
|
*
|
||||||
|
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
|
||||||
|
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
|
||||||
|
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
|
||||||
|
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { AgentCommand } from './provider-types.js';
|
||||||
|
|
||||||
|
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
|
||||||
|
function frontmatterField(content: string, field: string): string | undefined {
|
||||||
|
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!block?.[1]) return undefined;
|
||||||
|
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
||||||
|
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCommandDir(dir: string): AgentCommand[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith('.md')) continue;
|
||||||
|
let description: string | undefined;
|
||||||
|
try {
|
||||||
|
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
|
||||||
|
} catch {
|
||||||
|
/* unreadable — still list the command by name */
|
||||||
|
}
|
||||||
|
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSkillDir(dir: string): AgentCommand[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
for (const sub of entries) {
|
||||||
|
const skillMd = join(dir, sub, 'SKILL.md');
|
||||||
|
if (!existsSync(skillMd)) continue;
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = readFileSync(skillMd, 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
name: frontmatterField(content, 'name') ?? sub,
|
||||||
|
kind: 'skill',
|
||||||
|
...(() => {
|
||||||
|
const d = frontmatterField(content, 'description');
|
||||||
|
return d ? { description: d } : {};
|
||||||
|
})(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverClaudeCommands(): AgentCommand[] {
|
||||||
|
const root = join(homedir(), '.claude');
|
||||||
|
const out: AgentCommand[] = [];
|
||||||
|
|
||||||
|
// User custom commands.
|
||||||
|
out.push(...readCommandDir(join(root, 'commands')));
|
||||||
|
|
||||||
|
// Enabled plugins (user-scope installs).
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||||
|
enabledPlugins?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
const installed = JSON.parse(
|
||||||
|
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
|
||||||
|
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
|
||||||
|
|
||||||
|
const enabled = settings.enabledPlugins ?? {};
|
||||||
|
const plugins = installed.plugins ?? {};
|
||||||
|
for (const [key, on] of Object.entries(enabled)) {
|
||||||
|
if (!on) continue;
|
||||||
|
const installs = plugins[key] ?? [];
|
||||||
|
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
|
||||||
|
if (!installPath || !existsSync(installPath)) continue;
|
||||||
|
out.push(...readSkillDir(join(installPath, 'skills')));
|
||||||
|
out.push(...readCommandDir(join(installPath, 'commands')));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* missing/unreadable plugin config → user commands only */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by name (first wins).
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
|
||||||
|
}
|
||||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
|
||||||
|
*
|
||||||
|
* Uses execFile (NO shell) because the binary name can come from the provider
|
||||||
|
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
|
||||||
|
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
|
||||||
|
* `install_path`); this returns a boolean. Kept separate rather than over-
|
||||||
|
* refactored into one helper — different return contracts, two short call sites.
|
||||||
|
*/
|
||||||
|
import { execFile as execFileCb } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
|
export async function isCommandAvailable(binary: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
|
||||||
|
return stdout.trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cursor model list parser — lifted from Paseo cursor-acp-agent.ts
|
|
||||||
*/
|
|
||||||
import type { ProviderModel } from './provider-types.js';
|
|
||||||
|
|
||||||
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
|
|
||||||
|
|
||||||
export function parseCursorAgentModelsOutput(output: string): ProviderModel[] {
|
|
||||||
const parsed = output
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line && line !== 'Available models' && !line.startsWith('Tip:'))
|
|
||||||
.map((line) => {
|
|
||||||
const separatorIndex = line.indexOf(' - ');
|
|
||||||
if (separatorIndex <= 0) return null;
|
|
||||||
|
|
||||||
const id = line.slice(0, separatorIndex).trim();
|
|
||||||
const rawLabel = line.slice(separatorIndex + 3).trim();
|
|
||||||
if (!id || !rawLabel) return null;
|
|
||||||
|
|
||||||
let marker: 'default' | 'current' | null = null;
|
|
||||||
if (rawLabel.endsWith(' (default)')) marker = 'default';
|
|
||||||
else if (rawLabel.endsWith(' (current)')) marker = 'current';
|
|
||||||
|
|
||||||
return { id, label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ''), marker };
|
|
||||||
})
|
|
||||||
.filter((m): m is { id: string; label: string; marker: 'default' | 'current' | null } => m !== null);
|
|
||||||
|
|
||||||
const defaultModelId =
|
|
||||||
parsed.find((m) => m.marker === 'default')?.id ??
|
|
||||||
parsed.find((m) => m.marker === 'current')?.id ??
|
|
||||||
parsed[0]?.id;
|
|
||||||
|
|
||||||
return parsed.map((model) => ({
|
|
||||||
id: model.id,
|
|
||||||
label: model.label,
|
|
||||||
isDefault: model.id === defaultModelId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
|
|||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
|
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||||
import { getManifestCommands } from './provider-commands.js';
|
import { getManifestCommands } from './provider-commands.js';
|
||||||
@@ -24,16 +25,29 @@ interface Deps {
|
|||||||
config: Config;
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
|
||||||
|
// tasks immediately. The poll is only a safety net for notifications missed
|
||||||
|
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
const COMPLETION_POLL_MS = 2_000;
|
const COMPLETION_POLL_MS = 2_000;
|
||||||
|
|
||||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||||
const { sql, inference, broker, log, config } = deps;
|
const { sql, inference, broker, log, config } = deps;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||||
let running = false;
|
let running = false;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let inflightPromise: Promise<void> | null = null;
|
let inflightPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||||
|
// `running`/`stopping` guard makes this safe to call concurrently — a notify
|
||||||
|
// arriving mid-task returns immediately and never double-dispatches.
|
||||||
|
function triggerPoll(reason: string): void {
|
||||||
|
poll().catch((err) => {
|
||||||
|
log.error({ err, reason }, 'dispatcher: poll error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function poll(): Promise<void> {
|
async function poll(): Promise<void> {
|
||||||
if (running || stopping) return;
|
if (running || stopping) return;
|
||||||
|
|
||||||
@@ -327,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
if (supportsAcp) {
|
if (supportsAcp) {
|
||||||
const result = await dispatchViaAcp({
|
const result = await dispatchViaAcp({
|
||||||
agent,
|
agent,
|
||||||
|
resolved: getResolvedRegistry().get(agent),
|
||||||
task: task.input,
|
task: task.input,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
installPath: installPath ?? undefined,
|
installPath: installPath ?? undefined,
|
||||||
@@ -463,12 +478,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
start() {
|
start() {
|
||||||
log.info('dispatcher: starting poll loop');
|
log.info('dispatcher: starting poll loop + tasks_new listener');
|
||||||
timer = setInterval(() => {
|
|
||||||
poll().catch((err) => {
|
// Fallback poll — catches notifications missed while the listen connection
|
||||||
log.error({ err }, 'dispatcher: poll error');
|
// was down. The fast path is the NOTIFY listener below.
|
||||||
|
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Fast path: react immediately to new tasks. porsager reserves a dedicated
|
||||||
|
// connection and auto-resubscribes on reconnect; the onlisten callback
|
||||||
|
// fires on each (re)subscribe, so we kick a catch-up poll there too to
|
||||||
|
// sweep up anything inserted during a disconnect.
|
||||||
|
sql
|
||||||
|
.listen(
|
||||||
|
'tasks_new',
|
||||||
|
() => triggerPoll('notify'),
|
||||||
|
() => triggerPoll('listen-subscribed'),
|
||||||
|
)
|
||||||
|
.then((meta) => {
|
||||||
|
listener = meta;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
|
||||||
});
|
});
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
@@ -477,6 +508,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
if (listener) {
|
||||||
|
await listener.unlisten().catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: unlisten error');
|
||||||
|
});
|
||||||
|
listener = null;
|
||||||
|
}
|
||||||
if (inflightPromise) {
|
if (inflightPromise) {
|
||||||
log.info('dispatcher: waiting for in-flight task');
|
log.info('dispatcher: waiting for in-flight task');
|
||||||
await inflightPromise;
|
await inflightPromise;
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ const OPENCODE_COMMANDS: AgentCommand[] = [
|
|||||||
{ name: 'export', description: 'Export session' },
|
{ name: 'export', description: 'Export session' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CURSOR_COMMANDS: AgentCommand[] = [
|
|
||||||
{ name: 'help', description: 'Show available slash commands' },
|
|
||||||
{ name: 'clear', description: 'Clear conversation' },
|
|
||||||
{ name: 'compact', description: 'Compact context' },
|
|
||||||
{ name: 'resume', description: 'Resume a prior session' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GOOSE_COMMANDS: AgentCommand[] = [
|
const GOOSE_COMMANDS: AgentCommand[] = [
|
||||||
{ name: 'help', description: 'Show available commands' },
|
{ name: 'help', description: 'Show available commands' },
|
||||||
{ name: 'clear', description: 'Clear conversation' },
|
{ name: 'clear', description: 'Clear conversation' },
|
||||||
@@ -49,23 +42,12 @@ const QWEN_COMMANDS: AgentCommand[] = [
|
|||||||
{ name: 'review', description: 'Review changes' },
|
{ name: 'review', description: 'Review changes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COPILOT_COMMANDS: AgentCommand[] = [
|
|
||||||
{ name: 'help', description: 'Show available commands' },
|
|
||||||
{ name: 'explain', description: 'Explain selected code' },
|
|
||||||
{ name: 'fix', description: 'Fix issues in context' },
|
|
||||||
{ name: 'tests', description: 'Generate or run tests' },
|
|
||||||
{ name: 'doc', description: 'Generate documentation' },
|
|
||||||
{ name: 'clear', description: 'Clear conversation' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** boocode harness uses /api/skills — merged on the frontend. */
|
/** boocode harness uses /api/skills — merged on the frontend. */
|
||||||
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
||||||
claude: CLAUDE_COMMANDS,
|
claude: CLAUDE_COMMANDS,
|
||||||
opencode: OPENCODE_COMMANDS,
|
opencode: OPENCODE_COMMANDS,
|
||||||
cursor: CURSOR_COMMANDS,
|
|
||||||
goose: GOOSE_COMMANDS,
|
goose: GOOSE_COMMANDS,
|
||||||
qwen: QWEN_COMMANDS,
|
qwen: QWEN_COMMANDS,
|
||||||
copilot: COPILOT_COMMANDS,
|
|
||||||
boocode: [],
|
boocode: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
133
apps/coder/src/services/provider-config-registry.ts
Normal file
133
apps/coder/src/services/provider-config-registry.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* v2.3 resolved provider registry — single in-memory source of truth after
|
||||||
|
* merging the hardcoded built-ins (provider-registry.ts) with the config file
|
||||||
|
* (provider-config.ts). Mirrors Paseo's buildProviderRegistry/addDerivedProviders.
|
||||||
|
*
|
||||||
|
* Phase 1 scope: build + expose the resolved registry. `launchCommand` is null
|
||||||
|
* for built-ins (the default argv is resolved at dispatch time in Phase 3) and
|
||||||
|
* is the config `command` for custom ACP entries. No DB columns (design.md §3.3);
|
||||||
|
* `enabled` lives in memory only.
|
||||||
|
*/
|
||||||
|
import type { ProviderDef } from './provider-registry.js';
|
||||||
|
import { PROVIDERS } from './provider-registry.js';
|
||||||
|
import { load, type CoderProvidersFile } from './provider-config.js';
|
||||||
|
|
||||||
|
export interface ResolvedProviderDef extends ProviderDef {
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isBuiltin: boolean;
|
||||||
|
isCustomAcp: boolean;
|
||||||
|
/** Full argv for spawn: [binary, ...args]. Null for built-ins (resolved at dispatch). */
|
||||||
|
launchCommand: [string, ...string[]] | null;
|
||||||
|
env: Record<string, string> | undefined;
|
||||||
|
configLabel?: string;
|
||||||
|
configDescription?: string;
|
||||||
|
/** Config `models` — REPLACES the discovered/static model list when present. */
|
||||||
|
configModels?: Array<{ id: string; label: string }>;
|
||||||
|
/** Config `additionalModels` — MERGED on top of the resolved model list. */
|
||||||
|
configAdditionalModels?: Array<{ id: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge built-ins with config overrides into the resolved registry.
|
||||||
|
* Algorithm verbatim from design.md §3.1.
|
||||||
|
*/
|
||||||
|
export function buildResolvedRegistry(
|
||||||
|
builtins: ProviderDef[],
|
||||||
|
config: CoderProvidersFile,
|
||||||
|
): Map<string, ResolvedProviderDef> {
|
||||||
|
const out = new Map<string, ResolvedProviderDef>();
|
||||||
|
const overrides = config.providers ?? {};
|
||||||
|
const builtinNames = new Set(builtins.map((b) => b.name));
|
||||||
|
|
||||||
|
// 1. Built-ins, applying a config override if one is present.
|
||||||
|
for (const def of builtins) {
|
||||||
|
const ov = overrides[def.name];
|
||||||
|
let enabled = ov?.enabled !== false;
|
||||||
|
|
||||||
|
// 3. boocode is always enabled; an enabled:false override is ignored + warned.
|
||||||
|
if (def.name === 'boocode' && ov?.enabled === false) {
|
||||||
|
console.warn("provider-config: ignoring enabled:false for built-in 'boocode' (always enabled)");
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchCommand =
|
||||||
|
ov?.command && ov.command.length > 0 ? (ov.command as [string, ...string[]]) : null;
|
||||||
|
|
||||||
|
out.set(def.name, {
|
||||||
|
...def,
|
||||||
|
label: ov?.label ?? def.label,
|
||||||
|
id: def.name,
|
||||||
|
enabled,
|
||||||
|
isBuiltin: true,
|
||||||
|
isCustomAcp: false,
|
||||||
|
launchCommand,
|
||||||
|
env: ov?.env,
|
||||||
|
configLabel: ov?.label,
|
||||||
|
configDescription: ov?.description,
|
||||||
|
configModels: ov?.models,
|
||||||
|
configAdditionalModels: ov?.additionalModels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Config ids that are not built-ins → custom ACP entries.
|
||||||
|
for (const [id, ov] of Object.entries(overrides)) {
|
||||||
|
if (builtinNames.has(id)) continue;
|
||||||
|
// §2.2 rules: "New id without extends → Reject at load with log."
|
||||||
|
if (ov.extends !== 'acp' || !ov.label || !ov.command || ov.command.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
`provider-config: skipping custom provider '${id}' — requires extends:'acp', label, and command`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.set(id, {
|
||||||
|
name: id,
|
||||||
|
label: ov.label,
|
||||||
|
transport: 'acp',
|
||||||
|
modelSource: 'probe',
|
||||||
|
id,
|
||||||
|
enabled: ov.enabled !== false,
|
||||||
|
isBuiltin: false,
|
||||||
|
isCustomAcp: true,
|
||||||
|
launchCommand: ov.command as [string, ...string[]],
|
||||||
|
env: ov.env,
|
||||||
|
configLabel: ov.label,
|
||||||
|
configDescription: ov.description,
|
||||||
|
configModels: ov.models,
|
||||||
|
configAdditionalModels: ov.additionalModels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Module singleton ---------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedRegistry: Map<string, ResolvedProviderDef> | null = null;
|
||||||
|
let cachedPath: string | null = null;
|
||||||
|
|
||||||
|
/** Load the config file at `path`, rebuild, and cache the resolved registry. */
|
||||||
|
export function loadProviderConfig(path: string): Map<string, ResolvedProviderDef> {
|
||||||
|
cachedPath = path;
|
||||||
|
cachedRegistry = buildResolvedRegistry(PROVIDERS, load(path));
|
||||||
|
return cachedRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read the last-loaded config file and rebuild (Phase 4 calls this after PATCH). */
|
||||||
|
export function reloadProviderConfig(): Map<string, ResolvedProviderDef> {
|
||||||
|
if (cachedPath == null) {
|
||||||
|
cachedRegistry = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||||
|
return cachedRegistry;
|
||||||
|
}
|
||||||
|
return loadProviderConfig(cachedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cached resolved registry (built-ins only if nothing has been loaded yet). */
|
||||||
|
export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
|
||||||
|
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved provider ids in registry order. */
|
||||||
|
export function getResolvedProviderIds(): string[] {
|
||||||
|
return [...getResolvedRegistry().keys()];
|
||||||
|
}
|
||||||
65
apps/coder/src/services/provider-config.ts
Normal file
65
apps/coder/src/services/provider-config.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* v2.3 provider config file (`/data/coder-providers.json`) — schema + loader.
|
||||||
|
*
|
||||||
|
* Layers config-backed overrides/custom-ACP entries over the hardcoded built-ins
|
||||||
|
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
|
||||||
|
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
|
||||||
|
* `{ providers: {} }` (built-ins only, all enabled).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Schemas verbatim from design.md §2.2.
|
||||||
|
export const ProviderOverrideSchema = z.object({
|
||||||
|
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
|
||||||
|
label: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
enabled: z.boolean().optional(), // default true
|
||||||
|
order: z.number().int().optional(), // UI sort key
|
||||||
|
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||||
|
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CoderProvidersFileSchema = z.object({
|
||||||
|
providers: z.record(ProviderOverrideSchema).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||||
|
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||||
|
|
||||||
|
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||||
|
export function load(path: string): CoderProvidersFile {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(path, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Missing file → built-ins only. Expected, not an error.
|
||||||
|
return { providers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`provider-config: invalid JSON in ${path} — using built-ins only`, err);
|
||||||
|
return { providers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = CoderProvidersFileSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error(
|
||||||
|
`provider-config: schema validation failed for ${path} — using built-ins only`,
|
||||||
|
parsed.error.flatten(),
|
||||||
|
);
|
||||||
|
return { providers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the config back to disk (used by the Phase 4 PATCH route). */
|
||||||
|
export function save(path: string, config: CoderProvidersFile): void {
|
||||||
|
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
@@ -24,31 +24,6 @@ const OPENCODE_MODES: ProviderMode[] = [
|
|||||||
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
|
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COPILOT_MODES: ProviderMode[] = [
|
|
||||||
{
|
|
||||||
id: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
|
||||||
label: 'Agent',
|
|
||||||
description: 'Default agent mode',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'https://agentclientprotocol.com/protocol/session-modes#plan',
|
|
||||||
label: 'Plan',
|
|
||||||
description: 'Plan mode for multi-step work',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'allow-all',
|
|
||||||
label: 'Allow All',
|
|
||||||
description: 'Automatically approves all tool, path, and URL requests',
|
|
||||||
isUnattended: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CURSOR_CLI_MODES: ProviderMode[] = [
|
|
||||||
{ id: 'agent', label: 'Agent', description: 'Full agent capabilities with tool access' },
|
|
||||||
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
|
|
||||||
{ id: 'ask', label: 'Ask', description: 'Q&A read-only mode' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const QWEN_PTY_MODES: ProviderMode[] = [
|
const QWEN_PTY_MODES: ProviderMode[] = [
|
||||||
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
||||||
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
||||||
@@ -75,14 +50,6 @@ export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
|||||||
defaultModeId: 'build',
|
defaultModeId: 'build',
|
||||||
modes: OPENCODE_MODES,
|
modes: OPENCODE_MODES,
|
||||||
},
|
},
|
||||||
copilot: {
|
|
||||||
defaultModeId: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
|
||||||
modes: COPILOT_MODES,
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
defaultModeId: 'agent',
|
|
||||||
modes: CURSOR_CLI_MODES,
|
|
||||||
},
|
|
||||||
goose: {
|
goose: {
|
||||||
defaultModeId: null,
|
defaultModeId: null,
|
||||||
modes: [],
|
modes: [],
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export interface ProviderDef {
|
|||||||
* - boocode: llama-swap only
|
* - boocode: llama-swap only
|
||||||
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
||||||
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
||||||
* - cursor: ACP probe + cursor-agent models CLI fallback
|
* - goose: ACP probe only
|
||||||
* - goose / copilot: ACP probe only
|
|
||||||
* - claude: static manifest models + thinking options
|
* - claude: static manifest models + thinking options
|
||||||
*/
|
*/
|
||||||
export const PROVIDERS: ProviderDef[] = [
|
export const PROVIDERS: ProviderDef[] = [
|
||||||
@@ -24,12 +23,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'native',
|
transport: 'native',
|
||||||
modelSource: 'llama-swap',
|
modelSource: 'llama-swap',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'cursor',
|
|
||||||
label: 'Cursor Agent',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'opencode',
|
name: 'opencode',
|
||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
@@ -48,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
transport: 'pty',
|
transport: 'pty',
|
||||||
modelSource: 'static',
|
modelSource: 'static',
|
||||||
|
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
|
||||||
|
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
|
||||||
|
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
|
||||||
|
// exact version. Extend/replace per-install via data/coder-providers.json
|
||||||
|
// (models / additionalModels) without a code change.
|
||||||
staticModels: [
|
staticModels: [
|
||||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
{ id: 'opus', label: 'Opus (latest)' },
|
||||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
|
||||||
|
{ id: 'sonnet', label: 'Sonnet (latest)' },
|
||||||
|
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
|
||||||
|
{ id: 'haiku', label: 'Haiku (latest)' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,12 +61,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'acp',
|
transport: 'acp',
|
||||||
modelSource: 'probe',
|
modelSource: 'probe',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'copilot',
|
|
||||||
label: 'GitHub Copilot',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||||
|
|||||||
@@ -2,32 +2,31 @@
|
|||||||
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
||||||
*/
|
*/
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { exec as execCb } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
|
||||||
import {
|
import {
|
||||||
getManifestDefaultModeId,
|
getManifestDefaultModeId,
|
||||||
getManifestModes,
|
getManifestModes,
|
||||||
PROVIDER_MANIFEST,
|
PROVIDER_MANIFEST,
|
||||||
} from './provider-manifest.js';
|
} from './provider-manifest.js';
|
||||||
import { probeAcpProvider } from './acp-probe.js';
|
import { probeAcpProvider } from './acp-probe.js';
|
||||||
import { parseCursorAgentModelsOutput } from './cursor-models.js';
|
import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
|
||||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
|
||||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
const exec = promisify(execCb);
|
import { isCommandAvailable } from './command-availability.js';
|
||||||
|
import { discoverClaudeCommands } from './claude-command-discovery.js';
|
||||||
|
|
||||||
interface AgentRow {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
install_path: string | null;
|
install_path: string | null;
|
||||||
supports_acp: boolean;
|
supports_acp: boolean;
|
||||||
models: ProviderModel[] | null;
|
models: ProviderModel[] | null;
|
||||||
|
commands: AgentCommand[] | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
|
last_probed_at: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||||
@@ -41,15 +40,6 @@ async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
|
|
||||||
return parseCursorAgentModelsOutput(stdout);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
||||||
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
||||||
return models.map((m) => ({
|
return models.map((m) => ({
|
||||||
@@ -82,112 +72,155 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildProviderEntry(
|
async function buildProviderEntry(
|
||||||
provider: ProviderDef,
|
resolved: ResolvedProviderDef,
|
||||||
agentRow: AgentRow | undefined,
|
agentRow: AgentRow | undefined,
|
||||||
llamaModels: ProviderModel[],
|
llamaModels: ProviderModel[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<ProviderSnapshotEntry | null> {
|
ttlMs: number,
|
||||||
const isNative = provider.name === 'boocode';
|
force: boolean,
|
||||||
const installed = isNative || !!agentRow;
|
): Promise<ProviderSnapshotEntry> {
|
||||||
if (!installed) return null;
|
const name = resolved.id;
|
||||||
|
const isNative = resolved.transport === 'native';
|
||||||
|
const fallbackModes = getManifestModes(name);
|
||||||
|
const defaultModeId = getManifestDefaultModeId(name);
|
||||||
|
const manifestCommands = getManifestCommands(name);
|
||||||
|
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
|
||||||
|
// the agent's discovered commands show even when the tier-2 probe is skipped.
|
||||||
|
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
|
||||||
|
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||||
|
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||||
|
|
||||||
let transport = provider.transport;
|
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
|
||||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
// MERGES on top. Applied to every ready/installed model list below.
|
||||||
|
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
|
||||||
|
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
|
||||||
|
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
|
||||||
|
out = mergeModels(out, resolved.configAdditionalModels);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
|
||||||
|
let transport = resolved.transport;
|
||||||
|
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
||||||
transport = 'pty';
|
transport = 'pty';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackModes = getManifestModes(provider.name);
|
// 1. Disabled → unavailable, no probe.
|
||||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
if (!resolved.enabled) {
|
||||||
|
|
||||||
if (isNative) {
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, ...descr, transport, status: 'unavailable',
|
||||||
label: provider.label,
|
enabled: false, installed: false, models: [], modes: fallbackModes,
|
||||||
transport,
|
defaultModeId, commands: manifestCommands,
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models: llamaModels,
|
|
||||||
modes: [],
|
|
||||||
defaultModeId: null,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Native boocode → always ready (llama-swap models).
|
||||||
|
if (isNative) {
|
||||||
|
return {
|
||||||
|
name, label: resolved.label, transport, status: 'ready',
|
||||||
|
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
||||||
|
defaultModeId: null, commands: manifestCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
|
||||||
|
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
|
||||||
|
const fast =
|
||||||
|
agentRow?.install_path != null ||
|
||||||
|
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
|
||||||
|
|
||||||
|
if (!fast) {
|
||||||
|
return {
|
||||||
|
name, label, ...descr, transport, status: 'unavailable',
|
||||||
|
enabled: true, installed: false, models: [], modes: fallbackModes,
|
||||||
|
defaultModeId, commands: manifestCommands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline model precedence (used by claude + non-probe fallbacks).
|
||||||
let models: ProviderModel[] = [];
|
let models: ProviderModel[] = [];
|
||||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
|
||||||
models = llamaModels;
|
models = llamaModels;
|
||||||
} else if (agentRow?.models?.length) {
|
} else if (agentRow?.models?.length) {
|
||||||
models = agentRow.models;
|
models = agentRow.models;
|
||||||
} else if (provider.staticModels) {
|
} else if (resolved.staticModels) {
|
||||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.name === 'claude') {
|
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||||
models = attachClaudeThinking(models);
|
if (name === 'claude') {
|
||||||
|
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
|
||||||
|
// skills from disk live (the snapshot cache rate-limits the fs reads).
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
label: agentRow?.label ?? provider.label,
|
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||||
transport,
|
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models,
|
|
||||||
modes: fallbackModes,
|
|
||||||
defaultModeId,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
const canProbeAcp =
|
||||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
transport === 'acp' &&
|
||||||
if (probe.models.length > 0) {
|
((agentRow?.install_path != null && agentRow.supports_acp) ||
|
||||||
models = probe.models;
|
(resolved.isCustomAcp && resolved.launchCommand != null));
|
||||||
} else if (provider.name === 'cursor' && agentRow.install_path) {
|
|
||||||
models = await fetchCursorModelsCli(agentRow.install_path);
|
if (canProbeAcp) {
|
||||||
} else if (provider.modelSource === 'llama-swap') {
|
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
|
||||||
models = llamaModels;
|
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
|
||||||
|
const lastProbedMs =
|
||||||
|
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
|
||||||
|
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
|
||||||
|
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
|
||||||
|
const runTier2 = force || stale || dbEmpty;
|
||||||
|
|
||||||
|
if (!runTier2) {
|
||||||
|
let skipModels = agentRow?.models ?? [];
|
||||||
|
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||||
|
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
|
||||||
|
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
|
||||||
|
skipModels = llamaModels;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
|
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.name === 'qwen') {
|
const probeTarget =
|
||||||
const settingsModels = await readQwenSettingsModels();
|
resolved.isCustomAcp && resolved.launchCommand
|
||||||
models = mergeModels(models, settingsModels);
|
? resolved.launchCommand[0]
|
||||||
}
|
: agentRow!.install_path!;
|
||||||
|
const probe = await probeAcpProvider(name, probeTarget, cwd);
|
||||||
|
|
||||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
let probeModels = probe.models.length > 0 ? probe.models : models;
|
||||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
if (name === 'qwen') {
|
||||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
|
||||||
|
}
|
||||||
|
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||||
|
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
|
||||||
|
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport,
|
||||||
label: agentRow.label ?? provider.label,
|
|
||||||
transport,
|
|
||||||
status: probe.ok ? 'ready' : 'error',
|
status: probe.ok ? 'ready' : 'error',
|
||||||
installed: true,
|
enabled: true, installed: true,
|
||||||
models,
|
models: withConfigModels(probeModels),
|
||||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
commands: mergeCommands(manifestCommands, probe.commands),
|
||||||
error: probe.error,
|
...(probe.error ? { error: probe.error } : {}),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
|
||||||
if (provider.name === 'qwen') {
|
if (name === 'qwen' && models.length === 0) {
|
||||||
if (models.length === 0) {
|
models = await readQwenSettingsModels();
|
||||||
models = await readQwenSettingsModels();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: provider.name,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
label: agentRow?.label ?? provider.label,
|
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
transport,
|
|
||||||
status: 'ready',
|
|
||||||
installed: true,
|
|
||||||
models,
|
|
||||||
modes: fallbackModes,
|
|
||||||
defaultModeId,
|
|
||||||
commands: getManifestCommands(provider.name),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,16 +249,16 @@ export async function getProviderSnapshot(
|
|||||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||||
const llamaModels = await fetchLlamaSwapModels(config);
|
const llamaModels = await fetchLlamaSwapModels(config);
|
||||||
const agents = await sql<AgentRow[]>`
|
const agents = await sql<AgentRow[]>`
|
||||||
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
|
||||||
`;
|
`;
|
||||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||||
|
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||||
|
|
||||||
const built = await Promise.all(
|
const entries = await Promise.all(
|
||||||
PROVIDERS.map((provider) =>
|
[...getResolvedRegistry().values()].map((resolved) =>
|
||||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
|
||||||
|
|
||||||
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||||
return entries;
|
return entries;
|
||||||
@@ -235,6 +268,13 @@ export async function getProviderSnapshot(
|
|||||||
snapshotInflight.delete(cacheKey);
|
snapshotInflight.delete(cacheKey);
|
||||||
});
|
});
|
||||||
snapshotInflight.set(cacheKey, promise);
|
snapshotInflight.set(cacheKey, promise);
|
||||||
|
|
||||||
|
// Await the build (force or cache-miss) and return terminal entries. The sync
|
||||||
|
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
|
||||||
|
// poll that resolves it: without that poll, a single fetch lands on
|
||||||
|
// installed:false `loading` entries, which AgentComposerBar filters out
|
||||||
|
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
|
||||||
|
// once available_agents.models is warm.
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,16 +291,34 @@ export async function persistProbedModels(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
if (entry.name === 'boocode') continue;
|
||||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
let persisted = false;
|
||||||
await sql`
|
if (entry.models.length > 0) {
|
||||||
UPDATE available_agents
|
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
await sql`
|
||||||
WHERE name = ${entry.name}
|
UPDATE available_agents
|
||||||
`;
|
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||||
count++;
|
WHERE name = ${entry.name}
|
||||||
|
`;
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
// Persist captured ACP commands so they survive the tier-2 probe skip and
|
||||||
|
// show without a dispatch. Only when non-empty — never clobber a prior set.
|
||||||
|
if (entry.commands.length > 0) {
|
||||||
|
const flatCommands = entry.commands.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
...(c.description ? { description: c.description } : {}),
|
||||||
|
}));
|
||||||
|
await sql`
|
||||||
|
UPDATE available_agents
|
||||||
|
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
|
||||||
|
WHERE name = ${entry.name}
|
||||||
|
`;
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
if (persisted) count++;
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,24 +23,34 @@ export interface ProviderModel {
|
|||||||
defaultThinkingOptionId?: string;
|
defaultThinkingOptionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||||
|
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||||
|
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||||
|
|
||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||||
|
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||||
|
kind?: 'command' | 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||||
|
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||||
export interface ProviderSnapshotEntry {
|
export interface ProviderSnapshotEntry {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
status: ProviderSnapshotStatus;
|
status: ProviderSnapshotStatus;
|
||||||
|
enabled: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
modes: ProviderMode[];
|
modes: ProviderMode[];
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const ConfigSchema = z.object({
|
|||||||
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||||
// session model (auto_name) or DEFAULT_MODEL when unset.
|
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||||
FAST_MODEL: z.string().optional(),
|
FAST_MODEL: z.string().optional(),
|
||||||
|
TASK_MODEL_URL: z.string().url().optional(),
|
||||||
|
LLAMA_SIDECAR_URL: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { cleanupTruncations } from './services/truncate.js';
|
|||||||
import { loadMcpConfig } from './services/mcp-config.js';
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
import { appendMcpTools } from './services/tools.js';
|
import { appendMcpTools } from './services/tools.js';
|
||||||
import { refreshToolNames } from './services/agents.js';
|
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -91,6 +91,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
|
// Boot-time guard: if any agent has llama_extra_args but LLAMA_SIDECAR_URL
|
||||||
|
// is unset, fail fast. Silent fallback would defeat per-agent flags.
|
||||||
|
if (!config.LLAMA_SIDECAR_URL) {
|
||||||
|
const { agents } = await getAgentsForProject('');
|
||||||
|
const offending = agents.find(a => a.llama_extra_args && a.llama_extra_args.length > 0);
|
||||||
|
if (offending) {
|
||||||
|
app.log.fatal(
|
||||||
|
{ agent: offending.name },
|
||||||
|
`Agent "${offending.name}" has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
-- v1.11: anchored rolling compaction.
|
-- v1.11: anchored rolling compaction.
|
||||||
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||||
@@ -366,3 +367,39 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT F
|
|||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||||
|
|
||||||
|
-- tasks table (provider dispatch, arena)
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
arena_id UUID,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
mode_id TEXT,
|
||||||
|
thinking_option_id TEXT,
|
||||||
|
feature_values JSONB,
|
||||||
|
execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fix tasks FK to cascade on session delete (existing tables without CASCADE)
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'tasks_session_id_fkey'
|
||||||
|
AND confdeltype != 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT tasks_session_id_fkey;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|||||||
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseAgentsMd, matchToolGlob } from '../agents.js';
|
||||||
|
import { toolJsonSchemas } from '../tools.js';
|
||||||
|
|
||||||
|
describe('agent tool allowlist', () => {
|
||||||
|
const plannerMd = `# Agents
|
||||||
|
|
||||||
|
## Planner
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files]
|
||||||
|
description: Read-only planner
|
||||||
|
---
|
||||||
|
You plan.
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('parses an agent with a restricted tool allowlist', () => {
|
||||||
|
const { agents, errors } = parseAgentsMd(plannerMd);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(planner.name).toBe('Planner');
|
||||||
|
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, planner.tools),
|
||||||
|
);
|
||||||
|
const filteredNames = filtered.map((t) => t.function.name);
|
||||||
|
expect(filteredNames).toContain('view_file');
|
||||||
|
expect(filteredNames).toContain('grep');
|
||||||
|
expect(filteredNames).not.toContain('edit_file');
|
||||||
|
expect(filteredNames).not.toContain('web_search');
|
||||||
|
expect(filteredNames).not.toContain('get_codebase_overview');
|
||||||
|
expect(filtered).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
|
||||||
|
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool-phase guard: allows tool call in agent allowlist', () => {
|
||||||
|
const { agents } = parseAgentsMd(plannerMd);
|
||||||
|
const planner = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null/absent tools field defaults to all tools (no regression)', () => {
|
||||||
|
const noToolsMd = `# Agents
|
||||||
|
|
||||||
|
## Default
|
||||||
|
---
|
||||||
|
temperature: 0.7
|
||||||
|
description: Uses all tools
|
||||||
|
---
|
||||||
|
Default agent.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(noToolsMd);
|
||||||
|
const agent = agents[0]!;
|
||||||
|
const allSchemas = toolJsonSchemas();
|
||||||
|
const filtered = allSchemas.filter((t) =>
|
||||||
|
matchToolGlob(t.function.name, agent.tools),
|
||||||
|
);
|
||||||
|
expect(filtered.length).toBe(allSchemas.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
|
||||||
|
const builderMd = `# Agents
|
||||||
|
|
||||||
|
## Builder
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||||
|
description: Read and write tools
|
||||||
|
---
|
||||||
|
You build.
|
||||||
|
`;
|
||||||
|
const { agents } = parseAgentsMd(builderMd);
|
||||||
|
const builder = agents[0]!;
|
||||||
|
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
|
||||||
|
expect(matchToolGlob('grep', builder.tools)).toBe(true);
|
||||||
|
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
|
||||||
|
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
|
||||||
|
expect(builder.tools).not.toContain('edit_file');
|
||||||
|
expect(builder.tools).not.toContain('create_file');
|
||||||
|
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
|
||||||
|
const allowlist = ['view_file', 'grep', 'list_dir'];
|
||||||
|
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
|
||||||
|
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveRoute, upstreamModel } from '../inference/provider.js';
|
||||||
|
|
||||||
|
describe('resolveRoute', () => {
|
||||||
|
it('routes to swap when agent is null', () => {
|
||||||
|
expect(resolveRoute(null)).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has no llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: null })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has empty llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: [] })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to sidecar when agent has llama_extra_args', () => {
|
||||||
|
const result = resolveRoute({ llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(result.route).toBe('sidecar');
|
||||||
|
expect(result.flags).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upstreamModel', () => {
|
||||||
|
const swapConfig = { LLAMA_SWAP_URL: 'http://localhost:8401' };
|
||||||
|
const fullConfig = {
|
||||||
|
LLAMA_SWAP_URL: 'http://localhost:8401',
|
||||||
|
LLAMA_SIDECAR_URL: 'http://localhost:8402',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns a model for swap route (no agent)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for swap route (agent without extra args)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: null });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for sidecar route', () => {
|
||||||
|
const model = upstreamModel(fullConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when sidecar route requested but URL missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
upstreamModel(swapConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] }),
|
||||||
|
).toThrow(/LLAMA_SIDECAR_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap for empty llama_extra_args array', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: [] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { InferenceContext } from './inference/index.js';
|
import type { InferenceContext } from './inference/index.js';
|
||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
const NAMING_SYSTEM_PROMPT =
|
const NAMING_SYSTEM_PROMPT =
|
||||||
'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:".';
|
'You name chat sessions. Reply with ONLY the title. 4 to 6 words. No quotes, no punctuation, no prefix.';
|
||||||
|
|
||||||
const MAX_TITLE_CHARS = 60;
|
const MAX_TITLE_CHARS = 80;
|
||||||
|
|
||||||
function cleanTitle(raw: string): string {
|
function cleanTitle(raw: string): string {
|
||||||
let name = raw.trim();
|
let name = raw.trim();
|
||||||
@@ -18,27 +19,7 @@ function cleanTitle(raw: string): string {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamingResponse {
|
// TODO: wire suggestTags after task model validation
|
||||||
choices?: Array<{
|
|
||||||
message?: {
|
|
||||||
content?: string;
|
|
||||||
reasoning_content?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTitleSource(data: NamingResponse): string {
|
|
||||||
const choice = data.choices?.[0]?.message;
|
|
||||||
if (!choice) return '';
|
|
||||||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
|
||||||
const reasoning = choice.reasoning_content ?? '';
|
|
||||||
if (reasoning.length === 0) return '';
|
|
||||||
const lines = reasoning
|
|
||||||
.split('\n')
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
return lines[lines.length - 1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function maybeAutoNameChat(
|
export async function maybeAutoNameChat(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
@@ -64,52 +45,29 @@ export async function maybeAutoNameChat(
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (chat.name !== null && chat.name !== '') return;
|
if (chat.name !== null && chat.name !== '') return;
|
||||||
|
|
||||||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
|
||||||
SELECT model FROM sessions WHERE id = ${sessionId}
|
SELECT role, content FROM messages
|
||||||
`;
|
|
||||||
// v2.0.5: prefer FAST_MODEL for cheap LLM calls (titles, summaries).
|
|
||||||
const model = ctx.config.FAST_MODEL ?? sessionRows[0]?.model;
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
|
||||||
SELECT content FROM messages
|
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role IN ('user', 'assistant')
|
||||||
AND status = 'complete'
|
AND status IN ('complete', 'ok')
|
||||||
AND content <> ''
|
AND content <> ''
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1
|
LIMIT 2
|
||||||
`;
|
`;
|
||||||
if (!assistantMsg[0]) return;
|
const userMsg = firstMsgs.find(m => m.role === 'user');
|
||||||
|
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
|
||||||
|
if (!assistantMsg) return;
|
||||||
|
|
||||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
let namingInput = '';
|
||||||
|
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
|
||||||
|
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
|
||||||
|
|
||||||
const body = {
|
const raw = await taskModelCompletion({
|
||||||
model,
|
system: NAMING_SYSTEM_PROMPT,
|
||||||
messages: [
|
user: namingInput,
|
||||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
maxTokens: 30,
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: assistantText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 30,
|
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
stream: false,
|
|
||||||
chat_template_kwargs: { enable_thinking: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as NamingResponse;
|
|
||||||
const raw = pickTitleSource(data);
|
|
||||||
const name = cleanTitle(raw);
|
const name = cleanTitle(raw);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
|||||||
// turns + deeper exploration without changing the safety floor materially —
|
// turns + deeper exploration without changing the safety floor materially —
|
||||||
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
||||||
// mode this cap was guarding against.
|
// mode this cap was guarding against.
|
||||||
export const BUDGET_READ_ONLY = 50;
|
export const BUDGET_READ_ONLY = 100;
|
||||||
export const BUDGET_NON_READ_ONLY = 10;
|
export const BUDGET_NON_READ_ONLY = 100;
|
||||||
export const BUDGET_NO_AGENT = 50;
|
export const BUDGET_NO_AGENT = 100;
|
||||||
|
|
||||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,84 @@
|
|||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
// TODO: When per-agent llama-server flag overrides are added, route them
|
|
||||||
// through validateExtraArgs (./llama-args-validator.ts) first.
|
|
||||||
|
|
||||||
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
|
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
|
||||||
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
|
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
|
||||||
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||||
// Tailscale topology and exposing it over the public internet is gated by
|
// Tailscale topology and exposing it over the public internet is gated by
|
||||||
// Authelia at the Caddy layer, not by API keys.
|
// Authelia at the Caddy layer, not by API keys.
|
||||||
|
//
|
||||||
|
// v2.4.1-sidecar: when the agent has llama_extra_args, route through
|
||||||
|
// llama-sidecar instead. A fresh provider is created per call (not cached)
|
||||||
|
// because the X-Agent-Flags header varies per agent. The llama-swap path
|
||||||
|
// stays cached since it has no per-request headers.
|
||||||
|
|
||||||
const cache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||||
|
|
||||||
function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
function getSwapProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||||
let provider = cache.get(baseURL);
|
let provider = swapCache.get(baseURL);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
provider = createOpenAICompatible({
|
provider = createOpenAICompatible({
|
||||||
name: 'llama-swap',
|
name: 'llama-swap',
|
||||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
|
||||||
// omits `stream_options.include_usage` from the request body. Without
|
|
||||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
|
||||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
|
||||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
|
||||||
// true here re-enables the per-stream usage payload across all models
|
|
||||||
// served via the llama-swap provider.
|
|
||||||
includeUsage: true,
|
includeUsage: true,
|
||||||
});
|
});
|
||||||
cache.set(baseURL, provider);
|
swapCache.set(baseURL, provider);
|
||||||
}
|
}
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upstreamModel(baseURL: string, modelId: string): LanguageModel {
|
function sidecarProvider(
|
||||||
return getProvider(baseURL).chatModel(modelId);
|
baseURL: string,
|
||||||
|
flags: string[],
|
||||||
|
): ReturnType<typeof createOpenAICompatible> {
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: 'llama-sidecar',
|
||||||
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
|
includeUsage: true,
|
||||||
|
headers: {
|
||||||
|
'X-Agent-Flags': flags.join(' '),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferenceRoute = 'swap' | 'sidecar';
|
||||||
|
|
||||||
|
export interface RoutingInfo {
|
||||||
|
route: InferenceRoute;
|
||||||
|
flags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentLike {
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigLike {
|
||||||
|
LLAMA_SWAP_URL: string;
|
||||||
|
LLAMA_SIDECAR_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||||
|
const flags = agent?.llama_extra_args;
|
||||||
|
if (flags && flags.length > 0) {
|
||||||
|
return { route: 'sidecar', flags };
|
||||||
|
}
|
||||||
|
return { route: 'swap', flags: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upstreamModel(
|
||||||
|
config: ConfigLike,
|
||||||
|
modelId: string,
|
||||||
|
agent?: AgentLike | null,
|
||||||
|
): LanguageModel {
|
||||||
|
const { route, flags } = resolveRoute(agent ?? null);
|
||||||
|
if (route === 'sidecar') {
|
||||||
|
const url = config.LLAMA_SIDECAR_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||||
|
}
|
||||||
|
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export async function streamCompletion(
|
|||||||
opts: StreamOptions,
|
opts: StreamOptions,
|
||||||
onDelta: (content: string) => void,
|
onDelta: (content: string) => void,
|
||||||
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<StreamResult> {
|
): Promise<StreamResult> {
|
||||||
const aiMessages = toModelMessages(messages);
|
const aiMessages = toModelMessages(messages);
|
||||||
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
||||||
@@ -195,7 +196,7 @@ export async function streamCompletion(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools
|
...(aiTools
|
||||||
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
@@ -458,7 +459,8 @@ export async function executeStreamPhase(
|
|||||||
}, USAGE_THROTTLE_MS - elapsed);
|
}, USAGE_THROTTLE_MS - elapsed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
agent,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Session, ToolCall } from '../../types/api.js';
|
import type { Agent, Session, ToolCall } from '../../types/api.js';
|
||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
||||||
@@ -98,7 +99,8 @@ export async function executeToolPhase(
|
|||||||
result: StreamResult,
|
result: StreamResult,
|
||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const content = stripToolMarkup(result.content, { final: true });
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
@@ -262,6 +264,31 @@ export async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (agent && !matchToolGlob(tc.name, agent.tools)) {
|
||||||
|
const stored = {
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: null,
|
||||||
|
truncated: false,
|
||||||
|
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
|
||||||
|
};
|
||||||
|
await insertParts(
|
||||||
|
ctx.sql,
|
||||||
|
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||||
|
...p,
|
||||||
|
message_id: toolMessageId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
ctx.publish(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: toolMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call_id: tc.id,
|
||||||
|
output: stored.output,
|
||||||
|
truncated: false,
|
||||||
|
error: stored.error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { ALL_TOOLS } from '../tools.js';
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
import { resolveProjectRoot } from '../path_guard.js';
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
@@ -254,6 +255,16 @@ export async function runAssistantTurn(
|
|||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserMsg?.content) {
|
||||||
|
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||||
|
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||||
|
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
let result: StreamResult;
|
let result: StreamResult;
|
||||||
@@ -281,7 +292,7 @@ export async function runAssistantTurn(
|
|||||||
// ---- tool phase ----
|
// ---- tool phase ----
|
||||||
let toolPhaseResult: ToolPhaseResult;
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
try {
|
try {
|
||||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Tool phase errors are unexpected (individual tool failures are
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
// caught inside executeToolPhase). Log and break.
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
|
|||||||
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||||
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||||
// paths match the same patterns. Empty or root-only paths return false.
|
// paths match the same patterns. Empty or root-only paths return false.
|
||||||
|
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
|
||||||
|
'.env.example',
|
||||||
|
'.env.sample',
|
||||||
|
'.env.template',
|
||||||
|
'.env.defaults',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isSecretPath(relPath: string): boolean {
|
export function isSecretPath(relPath: string): boolean {
|
||||||
if (!relPath) return false;
|
if (!relPath) return false;
|
||||||
const normalized = relPath.replace(/\\/g, '/');
|
const normalized = relPath.replace(/\\/g, '/');
|
||||||
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
|
|||||||
if (segments.length === 0) return false;
|
if (segments.length === 0) return false;
|
||||||
const base = segments[segments.length - 1]!;
|
const base = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
|
||||||
|
|
||||||
for (const compiled of COMPILED) {
|
for (const compiled of COMPILED) {
|
||||||
if (compiled.mode === 'basename') {
|
if (compiled.mode === 'basename') {
|
||||||
if (compiled.regex.test(base)) return true;
|
if (compiled.regex.test(base)) return true;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import type { Agent, Project, Session } from '../types/api.js';
|
import type { Agent, Project, Session } from '../types/api.js';
|
||||||
import { getAgentsMtimes } from './agents.js';
|
import { getAgentsMtimes } from './agents.js';
|
||||||
|
import { resolveRoute } from './inference/provider.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
@@ -98,6 +99,7 @@ export interface PrefixFingerprint {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrefixDrift {
|
export interface PrefixDrift {
|
||||||
@@ -125,6 +127,7 @@ interface ObservedInputs {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ObserverEntry {
|
interface ObserverEntry {
|
||||||
@@ -183,6 +186,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
||||||
has_session_override: sessionPrompt.length > 0,
|
has_session_override: sessionPrompt.length > 0,
|
||||||
has_project_override: projectPrompt.length > 0,
|
has_project_override: projectPrompt.length > 0,
|
||||||
|
route: resolveRoute(agent).route,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fingerprint: PrefixFingerprint = {
|
const fingerprint: PrefixFingerprint = {
|
||||||
@@ -199,6 +203,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
||||||
has_session_override: inputs.has_session_override,
|
has_session_override: inputs.has_session_override,
|
||||||
has_project_override: inputs.has_project_override,
|
has_project_override: inputs.has_project_override,
|
||||||
|
route: inputs.route,
|
||||||
};
|
};
|
||||||
|
|
||||||
let drift: PrefixDrift | null = null;
|
let drift: PrefixDrift | null = null;
|
||||||
|
|||||||
68
apps/server/src/services/task-model.ts
Normal file
68
apps/server/src/services/task-model.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { loadConfig, type Config } from '../config.js';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export async function taskModelCompletion(opts: {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
fallbackModel?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const maxTokens = opts.maxTokens ?? 30;
|
||||||
|
const temperature = opts.temperature ?? 0.3;
|
||||||
|
|
||||||
|
const { url, model } = resolveEndpoint(config, opts.fallbackModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.system },
|
||||||
|
{ role: 'user', content: opts.user },
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature,
|
||||||
|
stream: false,
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.warn(`task-model: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: { content?: string; reasoning_content?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const choice = data.choices?.[0]?.message;
|
||||||
|
if (!choice) return '';
|
||||||
|
const content = (choice.content ?? '').trim();
|
||||||
|
if (content.length > 0) return content;
|
||||||
|
const reasoning = choice.reasoning_content ?? '';
|
||||||
|
if (reasoning.length === 0) return '';
|
||||||
|
const lines = reasoning.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||||
|
return lines[lines.length - 1] ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('task-model: request failed', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndpoint(
|
||||||
|
config: Config,
|
||||||
|
fallbackModel?: string,
|
||||||
|
): { url: string; model: string } {
|
||||||
|
if (config.TASK_MODEL_URL) {
|
||||||
|
return { url: config.TASK_MODEL_URL, model: 'gemma-3-270m-it' };
|
||||||
|
}
|
||||||
|
const model = config.FAST_MODEL ?? fallbackModel ?? config.DEFAULT_MODEL;
|
||||||
|
return { url: config.LLAMA_SWAP_URL, model };
|
||||||
|
}
|
||||||
19
apps/server/src/services/task-search-rewrite.ts
Normal file
19
apps/server/src/services/task-search-rewrite.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You rewrite user messages into concise web search queries. Reply with ONLY the search query. 3 to 6 words. No quotes, no explanation.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 500;
|
||||||
|
const FALLBACK_CHARS = 60;
|
||||||
|
|
||||||
|
export async function rewriteSearchQuery(userMessage: string): Promise<string> {
|
||||||
|
const input = userMessage.slice(0, MAX_INPUT_CHARS);
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 20,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
if (result.length > 0) return result;
|
||||||
|
return userMessage.slice(0, FALLBACK_CHARS).trim();
|
||||||
|
}
|
||||||
24
apps/server/src/services/task-summary.ts
Normal file
24
apps/server/src/services/task-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 1000;
|
||||||
|
|
||||||
|
export async function oneLineSummary(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const lastPairs = messages.slice(-6);
|
||||||
|
let input = lastPairs
|
||||||
|
.map((m) => `${m.role}: ${m.content}`)
|
||||||
|
.join('\n');
|
||||||
|
if (input.length > MAX_INPUT_CHARS) {
|
||||||
|
input = input.slice(0, MAX_INPUT_CHARS);
|
||||||
|
}
|
||||||
|
return taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/server/src/services/task-tags.ts
Normal file
22
apps/server/src/services/task-tags.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".';
|
||||||
|
|
||||||
|
export async function suggestTags(
|
||||||
|
userMessage: string,
|
||||||
|
assistantReply: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`;
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
if (result.length === 0) return [];
|
||||||
|
return result
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter((t) => t.length > 0 && t.length <= 30);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
|||||||
// a right-side drawer toggled by the header's FolderTree button (via
|
// a right-side drawer toggled by the header's FolderTree button (via
|
||||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||||
// own internal open/close state.
|
// own internal open/close state.
|
||||||
return <RightRail projectId={projectId} />;
|
return <RightRail projectId={projectId} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileBackdrop() {
|
function MobileBackdrop() {
|
||||||
|
|||||||
@@ -332,20 +332,51 @@ export const api = {
|
|||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||||
),
|
),
|
||||||
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
|
skillInvoke: (
|
||||||
|
sessionId: string,
|
||||||
|
paneId: string,
|
||||||
|
skillName: string,
|
||||||
|
userMessage: string | null,
|
||||||
|
// v2.5.9: when the active provider is external, the skill runs under that
|
||||||
|
// agent (body injected into a dispatched task) → response carries task_id.
|
||||||
|
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
|
||||||
|
) =>
|
||||||
request<{
|
request<{
|
||||||
user_message_id: string;
|
user_message_id: string;
|
||||||
assistant_message_id: string;
|
assistant_message_id?: string;
|
||||||
synth_assistant_id: string;
|
synth_assistant_id?: string;
|
||||||
tool_message_id: string;
|
tool_message_id?: string;
|
||||||
|
task_id?: string;
|
||||||
|
dispatched?: boolean;
|
||||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pane_id: paneId,
|
pane_id: paneId,
|
||||||
skill_name: skillName,
|
skill_name: skillName,
|
||||||
user_message: userMessage,
|
user_message: userMessage,
|
||||||
|
...(config?.provider ? { provider: config.provider } : {}),
|
||||||
|
...(config?.model ? { model: config.model } : {}),
|
||||||
|
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
|
||||||
|
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
// Queue a new-file create from the RightRail browser → BooCoder
|
||||||
|
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
|
||||||
|
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
|
||||||
|
// body ApiError exposes as .message for inline display.
|
||||||
|
createPendingFile: (sessionId: string, file_path: string, content: string) =>
|
||||||
|
request<{
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ file_path, content }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -182,10 +182,14 @@ export interface Message {
|
|||||||
// majority of messages.
|
// majority of messages.
|
||||||
metadata: MessageMetadata | null;
|
metadata: MessageMetadata | null;
|
||||||
// v1.13.1-C: reasoning content captured from models that stream reasoning
|
// v1.13.1-C: reasoning content captured from models that stream reasoning
|
||||||
// tokens separately (qwen3.6 etc.). Backend populates from message_parts;
|
// tokens separately (qwen3.6 etc.) and from external agents over ACP
|
||||||
// optional on the wire — frontend doesn't render this yet (reserved for
|
// (agent_thought_chunk). Backend populates from message_parts; rendered by
|
||||||
// a v1.14 UI surface).
|
// MessageBubble as a collapsible "Thinking" block.
|
||||||
reasoning_parts?: Array<{ text: string }> | null;
|
reasoning_parts?: Array<{ text: string }> | null;
|
||||||
|
// Coder wire shape pre-joins reasoning_parts into a single string
|
||||||
|
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
|
||||||
|
// frames. MessageBubble reads whichever of the two is present.
|
||||||
|
reasoning_text?: string | null;
|
||||||
// v1.11: anchored rolling compaction fields. Optional on the wire so that
|
// v1.11: anchored rolling compaction fields. Optional on the wire so that
|
||||||
// older API responses (or test fixtures) parse without explicit nulls.
|
// older API responses (or test fixtures) parse without explicit nulls.
|
||||||
// summary — true on the assistant row that holds the active
|
// summary — true on the assistant row that holds the active
|
||||||
@@ -228,19 +232,25 @@ export interface ThinkingOption {
|
|||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||||
|
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||||
|
|
||||||
|
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||||
|
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||||
export interface ProviderSnapshotEntry {
|
export interface ProviderSnapshotEntry {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
status: ProviderSnapshotStatus;
|
status: ProviderSnapshotStatus;
|
||||||
|
enabled: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
modes: ProviderMode[];
|
modes: ProviderMode[];
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fetchedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
@@ -263,6 +273,9 @@ export interface PermissionPrompt {
|
|||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||||
|
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||||
|
kind?: 'command' | 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoderSendMessageBody {
|
export interface CoderSendMessageBody {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
|
|
||||||
export function AgentCommandsHint({ commands }: Props) {
|
export function AgentCommandsHint({ commands }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
if (commands.length === 0) return null;
|
if (commands.length === 0) return null;
|
||||||
|
|
||||||
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
|
|||||||
{open && (
|
{open && (
|
||||||
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
|
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
|
||||||
{commands.map((cmd) => (
|
{commands.map((cmd) => (
|
||||||
<li key={cmd.name} className="font-mono">
|
<li
|
||||||
<span className="text-primary/80">/{cmd.name}</span>
|
key={cmd.name}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-primary/80">/{cmd.name}</span>
|
||||||
{cmd.description && (
|
{cmd.description && (
|
||||||
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
|
<span className={cn(
|
||||||
|
'ml-1.5 text-muted-foreground font-sans',
|
||||||
|
expanded === cmd.name ? '' : 'line-clamp-2',
|
||||||
|
)}>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
|
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||||
|
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
@@ -91,9 +92,11 @@ interface PickerProps {
|
|||||||
options: Array<{ id: string; label: string }>;
|
options: Array<{ id: string; label: string }>;
|
||||||
onPick: (id: string) => void;
|
onPick: (id: string) => void;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||||
@@ -125,9 +128,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label={`${label}: ${currentLabel}`}
|
aria-label={`${label}: ${currentLabel}`}
|
||||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{icon ?? <Cpu className="size-4" />}
|
{icon}
|
||||||
|
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||||
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||||
<div className="px-2">{list}</div>
|
<div className="px-2">{list}</div>
|
||||||
@@ -142,16 +147,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]"
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{currentLabel}</span>
|
<span className="truncate max-w-[180px]">{currentLabel}</span>
|
||||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
|
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
|
||||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||||
{o.label}
|
{o.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -166,9 +171,10 @@ interface Props {
|
|||||||
value: AgentSessionConfig;
|
value: AgentSessionConfig;
|
||||||
onChange: (next: AgentSessionConfig) => void;
|
onChange: (next: AgentSessionConfig) => void;
|
||||||
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
||||||
|
connected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||||
const allEntries = useProviderSnapshot(projectPath);
|
const allEntries = useProviderSnapshot(projectPath);
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||||
@@ -255,6 +261,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerIcon = (name: string) => {
|
||||||
|
switch (name) {
|
||||||
|
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
|
||||||
|
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
|
||||||
|
case 'goose': return <Bird size={13} className="shrink-0" />;
|
||||||
|
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
|
||||||
|
default: return <Dog size={13} className="shrink-0" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||||
@@ -267,7 +283,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
value={value.provider}
|
value={value.provider}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
onPick={pickProvider}
|
onPick={pickProvider}
|
||||||
icon={<Cpu className="size-3 shrink-0" />}
|
icon={providerIcon(value.provider)}
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Mode"
|
||||||
@@ -276,6 +292,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
options={modeOptions}
|
options={modeOptions}
|
||||||
onPick={(modeId) => persist({ ...value, modeId })}
|
onPick={(modeId) => persist({ ...value, modeId })}
|
||||||
icon={<Shield className="size-3 shrink-0" />}
|
icon={<Shield className="size-3 shrink-0" />}
|
||||||
|
iconOnly
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Model"
|
label="Model"
|
||||||
@@ -283,6 +300,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
disabled={modelOptions.length === 0}
|
disabled={modelOptions.length === 0}
|
||||||
options={modelOptions}
|
options={modelOptions}
|
||||||
onPick={pickModel}
|
onPick={pickModel}
|
||||||
|
icon={<Bot size={13} className="shrink-0" />}
|
||||||
/>
|
/>
|
||||||
{thinkingOpts.length > 0 && (
|
{thinkingOpts.length > 0 && (
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
@@ -293,16 +311,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
icon={<Brain className="size-3 shrink-0" />}
|
icon={<Brain className="size-3 shrink-0" />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||||
type="button"
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||||
onClick={() => void handleRefresh()}
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||||
disabled={refreshing}
|
{connected !== undefined && (
|
||||||
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
<span
|
||||||
aria-label="Refresh provider list"
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||||
title="Refresh providers"
|
title={connected ? 'Connected' : 'Disconnected'}
|
||||||
>
|
/>
|
||||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRefresh()}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
aria-label="Refresh provider list"
|
||||||
|
title="Refresh providers"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
|||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
import { DropOverlay } from '@/components/DropOverlay';
|
import { DropOverlay } from '@/components/DropOverlay';
|
||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
|
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||||
import { ContextBar } from '@/components/ContextBar';
|
import { ContextBar } from '@/components/ContextBar';
|
||||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Message } from '@/api/types';
|
import type { Message } from '@/api/types';
|
||||||
@@ -55,6 +56,13 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
|
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
|
||||||
|
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
|
||||||
|
// groups are shown instead of the BooChat skills. Invocation routing still
|
||||||
|
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
|
||||||
|
// through and are sent to the agent as literal text. Omitted → BooChat skills
|
||||||
|
// (flat, unchanged — parity).
|
||||||
|
slashGroups?: SlashCommandGroup[];
|
||||||
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
// registers in chatInputsRegistry so the terminal floating menu can list
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
@@ -70,7 +78,7 @@ interface Props {
|
|||||||
modelContextLimit?: number | null;
|
modelContextLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -99,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
for (const s of skills) m.set(s.name, true);
|
for (const s of skills) m.set(s.name, true);
|
||||||
return m;
|
return m;
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
|
// Flat display source for the hint (and the picker's no-groups fallback):
|
||||||
|
// caller-provided groups flattened, else the BooChat skills.
|
||||||
|
const slashItems = useMemo(
|
||||||
|
() =>
|
||||||
|
slashGroups
|
||||||
|
? slashGroups.flatMap((g) => g.items)
|
||||||
|
: skills.map((s) => ({ name: s.name, description: s.description })),
|
||||||
|
[slashGroups, skills],
|
||||||
|
);
|
||||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -560,6 +577,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{slashItems.length > 0 && (
|
||||||
|
<AgentCommandsHint commands={slashItems} />
|
||||||
|
)}
|
||||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||||||
inlines ContextBar in the same row so the bar lives next to the
|
inlines ContextBar in the same row so the bar lives next to the
|
||||||
picker rather than as a separate header above it. The row renders
|
picker rather than as a separate header above it. The row renders
|
||||||
@@ -657,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
{slashState && (
|
{slashState && (
|
||||||
<SlashCommandPicker
|
<SlashCommandPicker
|
||||||
query={slashState.query}
|
query={slashState.query}
|
||||||
items={skills}
|
items={slashItems}
|
||||||
|
groups={slashGroups}
|
||||||
inputRef={textareaRef}
|
inputRef={textareaRef}
|
||||||
onSelect={handleSlashSelect}
|
onSelect={handleSlashSelect}
|
||||||
onClose={() => setSlashState(null)}
|
onClose={() => setSlashState(null)}
|
||||||
emptyLabel="No skills available"
|
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function ChatTabBar({
|
|||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
|
|||||||
return 'Markdown artifact';
|
return 'Markdown artifact';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageActions {
|
||||||
|
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||||
|
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
|
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
sessionChats?: Chat[];
|
sessionChats?: Chat[];
|
||||||
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
|
|
||||||
// Only the most recent sentinel shows the Continue button.
|
|
||||||
capHitInfo?: { position: number; isLatest: boolean };
|
capHitInfo?: { position: number; isLatest: boolean };
|
||||||
|
actions?: MessageActions;
|
||||||
|
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
||||||
|
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsLine({ message }: { message: Message }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
|
|||||||
|
|
||||||
function ActionRow({
|
function ActionRow({
|
||||||
message,
|
message,
|
||||||
|
actions,
|
||||||
|
hiddenSet,
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
actions?: MessageActions;
|
||||||
|
hiddenSet: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
@@ -180,7 +192,11 @@ function ActionRow({
|
|||||||
if (regenerating || message.status === 'streaming') return;
|
if (regenerating || message.status === 'streaming') return;
|
||||||
setRegenerating(true);
|
setRegenerating(true);
|
||||||
try {
|
try {
|
||||||
await api.messages.regenerate(message.chat_id, message.id);
|
if (actions?.onRegenerate) {
|
||||||
|
await actions.onRegenerate(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
await api.messages.regenerate(message.chat_id, message.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -188,12 +204,30 @@ function ActionRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resend() {
|
||||||
|
if (!canResend) return;
|
||||||
|
try {
|
||||||
|
if (actions?.onResend) {
|
||||||
|
await actions.onResend(message.chat_id, message.content!);
|
||||||
|
} else {
|
||||||
|
await api.messages.send(message.chat_id, message.content!);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'resend failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fork() {
|
async function fork() {
|
||||||
if (forking || message.status !== 'complete') return;
|
if (forking || message.status !== 'complete') return;
|
||||||
setForking(true);
|
setForking(true);
|
||||||
try {
|
try {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
if (actions?.onFork) {
|
||||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
await actions.onFork(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
|
sessionEvents.emit({ type: 'refetch_messages' });
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,7 +239,11 @@ function ActionRow({
|
|||||||
if (deleting) return;
|
if (deleting) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await api.messages.remove(message.chat_id, message.id);
|
if (actions?.onDelete) {
|
||||||
|
await actions.onDelete(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
await api.messages.remove(message.chat_id, message.id);
|
||||||
|
}
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'delete failed');
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||||
@@ -215,7 +253,9 @@ function ActionRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant';
|
const isAssistant = message.role === 'assistant';
|
||||||
|
const isUser = message.role === 'user';
|
||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
const canDelete = message.status !== 'streaming';
|
||||||
const [openingPane, setOpeningPane] = useState(false);
|
const [openingPane, setOpeningPane] = useState(false);
|
||||||
@@ -279,7 +319,18 @@ function ActionRow({
|
|||||||
>
|
>
|
||||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
{isAssistant && (
|
{canResend && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void resend()}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Resend message"
|
||||||
|
title="Resend"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAssistant && !hiddenSet.has('openInPane') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openInPane()}
|
onClick={() => void openInPane()}
|
||||||
@@ -303,26 +354,30 @@ function ActionRow({
|
|||||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!hiddenSet.has('fork') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void fork()}
|
type="button"
|
||||||
disabled={!canFork || forking}
|
onClick={() => void fork()}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
disabled={!canFork || forking}
|
||||||
aria-label="Fork from here"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
title="Fork from here"
|
aria-label="Fork from here"
|
||||||
>
|
title="Fork from here"
|
||||||
<GitFork className="size-3" />
|
>
|
||||||
</button>
|
<GitFork className="size-3" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => setDeleteOpen(true)}
|
{!hiddenSet.has('delete') && (
|
||||||
disabled={!canDelete}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={!canDelete}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Delete message"
|
aria-label="Delete message"
|
||||||
title="Delete message"
|
title="Delete message"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
@@ -536,7 +591,39 @@ function SummaryCard({ message }: { message: Message }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
||||||
|
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
||||||
|
// (native inference, persisted from message_parts). Auto-expands while the turn
|
||||||
|
// is still streaming so the user watches it think (Paseo-style), then stays
|
||||||
|
// where the user left it once the turn completes — initial state is captured
|
||||||
|
// once at mount, so we never fight a manual collapse on later re-renders.
|
||||||
|
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
||||||
|
const [expanded, setExpanded] = useState(() => streaming);
|
||||||
|
return (
|
||||||
|
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<Brain size={13} />
|
||||||
|
<span className="text-xs font-medium">Thinking</span>
|
||||||
|
{streaming && (
|
||||||
|
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
|
||||||
|
const hiddenSet = new Set(hideActions ?? []);
|
||||||
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||||
// branch because summary=true never coexists with kind='compact' (new
|
// branch because summary=true never coexists with kind='compact' (new
|
||||||
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
||||||
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
</SendToTerminalMenu>
|
</SendToTerminalMenu>
|
||||||
<ActionRow message={message} />
|
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||||
const hasContent = message.content.trim().length > 0;
|
const hasContent = message.content.trim().length > 0;
|
||||||
|
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
||||||
|
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
||||||
|
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
||||||
|
const reasoningText = (
|
||||||
|
message.reasoning_text ??
|
||||||
|
message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
const hasReasoning = reasoningText.length > 0;
|
||||||
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
||||||
// generic "message failed" line. Keeps the user's eye where it already is
|
// generic "message failed" line. Keeps the user's eye where it already is
|
||||||
// rather than introducing a separate banner.
|
// rather than introducing a separate banner.
|
||||||
const errorMeta =
|
const errorMeta =
|
||||||
message.metadata !== null && message.metadata.kind === 'error'
|
message.metadata != null && message.metadata.kind === 'error'
|
||||||
? message.metadata
|
? message.metadata
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col gap-2">
|
<div className="group flex flex-col gap-2">
|
||||||
|
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
|
||||||
{(hasContent || isStreaming) && (
|
{(hasContent || isStreaming) && (
|
||||||
<SendToTerminalMenu>
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||||
@@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isStreaming && <StatsLine message={message} />}
|
{!isStreaming && <StatsLine message={message} />}
|
||||||
{!isStreaming && hasContent && <ActionRow message={message} />}
|
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
|
|||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
{chat && (
|
{chat && (
|
||||||
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
<Edit2 size={14} /> Rename chat
|
<Edit2 size={14} /> Rename chat
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
|||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import type { FileEntry } from '@/api/types';
|
import type { FileEntry } from '@/api/types';
|
||||||
import { inferLanguage } from '@/lib/attachments';
|
import { inferLanguage } from '@/lib/attachments';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'boocode.rightrail';
|
const STORAGE_KEY = 'boocode.rightrail';
|
||||||
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
|
|||||||
return `${parent}/${name}`;
|
return `${parent}/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRail({ projectId }: Props) {
|
export function RightRail({ projectId, sessionId }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
|
|||||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||||
|
|
||||||
|
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
||||||
|
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
||||||
|
const [newFileOpen, setNewFileOpen] = useState(false);
|
||||||
|
const [newFilePath, setNewFilePath] = useState('');
|
||||||
|
const [newFileContent, setNewFileContent] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openNewFile = useCallback(() => {
|
||||||
|
setNewFilePath('');
|
||||||
|
setNewFileContent('');
|
||||||
|
setCreateError(null);
|
||||||
|
setNewFileOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitNewFile = useCallback(async () => {
|
||||||
|
const path = newFilePath.trim();
|
||||||
|
if (!path || creating) return;
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await api.coder.createPendingFile(sessionId, path, newFileContent);
|
||||||
|
setNewFileOpen(false);
|
||||||
|
setNewFilePath('');
|
||||||
|
setNewFileContent('');
|
||||||
|
} catch (err) {
|
||||||
|
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
|
||||||
|
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}, [sessionId, newFilePath, newFileContent, creating]);
|
||||||
|
|
||||||
// Combined open state: on mobile use the global drawer state (toggled by
|
// Combined open state: on mobile use the global drawer state (toggled by
|
||||||
// the Session header's FolderTree button); on desktop use the persistent
|
// the Session header's FolderTree button); on desktop use the persistent
|
||||||
// internal state.
|
// internal state.
|
||||||
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
|
|||||||
<aside className={asideCls}>
|
<aside className={asideCls}>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||||
<span className="text-xs font-medium flex-1">Files</span>
|
<span className="text-xs font-medium flex-1">Files</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openNewFile}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="New file from pasted text"
|
||||||
|
title="New file"
|
||||||
|
>
|
||||||
|
<FilePlus size={14} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeRail}
|
onClick={closeRail}
|
||||||
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
|
|||||||
onNavigate={(path) => void openFile(path)}
|
onNavigate={(path) => void openFile(path)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New file from pasted text</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Queues a new file as a pending change. Review and apply it from the Coder pane.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
|
||||||
|
<Input
|
||||||
|
id="new-file-path"
|
||||||
|
value={newFilePath}
|
||||||
|
onChange={(e) => setNewFilePath(e.target.value)}
|
||||||
|
placeholder="src/example.ts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="new-file-content"
|
||||||
|
value={newFileContent}
|
||||||
|
onChange={(e) => setNewFileContent(e.target.value)}
|
||||||
|
placeholder="Paste file contents here…"
|
||||||
|
autoFocus
|
||||||
|
className="min-h-[180px] font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,31 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat } from '@/api/types';
|
|
||||||
import { api } from '@/api/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from '@/components/ui/context-menu';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { formatTokens } from '@/lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
chats: Chat[];
|
sessionId: string;
|
||||||
onOpenChat: (chatId: string) => void;
|
agentId?: string | null;
|
||||||
|
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
/** Create a chat and return its id. Used by slash-command handler. */
|
// Slash-command (skill) send from the landing page. The parent creates the
|
||||||
|
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
|
||||||
|
// the skill — same transition the text send uses. See useSessionChats.
|
||||||
|
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||||
createChat: () => Promise<{ id: string }>;
|
createChat: () => Promise<{ id: string }>;
|
||||||
onReopenChat: (chatId: string) => Promise<void>;
|
|
||||||
onArchiveChat: (chatId: string) => Promise<void>;
|
|
||||||
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
|
||||||
onDeleteChat: (chatId: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relTime(iso: string): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const t = Date.parse(iso);
|
|
||||||
if (Number.isNaN(t)) return '';
|
|
||||||
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
||||||
if (sec < 60) return `${sec}s ago`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
if (min < 60) return `${min}m ago`;
|
|
||||||
const hr = Math.floor(min / 60);
|
|
||||||
if (hr < 24) return `${hr}h ago`;
|
|
||||||
const day = Math.floor(hr / 24);
|
|
||||||
return `${day}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatRowProps {
|
|
||||||
chat: Chat;
|
|
||||||
onClick: () => void;
|
|
||||||
dimmed?: boolean;
|
|
||||||
trailing?: React.ReactNode;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
renamingId: string | null;
|
|
||||||
renameValue: string;
|
|
||||||
setRenameValue: (s: string) => void;
|
|
||||||
onFinishRename: () => void;
|
|
||||||
onCancelRename: () => void;
|
|
||||||
onContextStartRename: () => void;
|
|
||||||
onContextArchive: () => void;
|
|
||||||
onContextDelete: () => void;
|
|
||||||
showContextMenu: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatRow({
|
|
||||||
chat,
|
|
||||||
onClick,
|
|
||||||
dimmed,
|
|
||||||
trailing,
|
|
||||||
actions,
|
|
||||||
renamingId,
|
|
||||||
renameValue,
|
|
||||||
setRenameValue,
|
|
||||||
onFinishRename,
|
|
||||||
onCancelRename,
|
|
||||||
onContextStartRename,
|
|
||||||
onContextArchive,
|
|
||||||
onContextDelete,
|
|
||||||
showContextMenu,
|
|
||||||
}: ChatRowProps) {
|
|
||||||
const meta: string[] = [relTime(chat.updated_at)];
|
|
||||||
if (chat.message_count !== undefined && chat.message_count > 0) {
|
|
||||||
meta.push(`${chat.message_count} msg`);
|
|
||||||
}
|
|
||||||
const tokens = formatTokens(chat.effective_context_tokens);
|
|
||||||
if (tokens) meta.push(tokens);
|
|
||||||
const preview = chat.last_message_preview;
|
|
||||||
const isRenaming = renamingId === chat.id;
|
|
||||||
|
|
||||||
const inner = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
|
|
||||||
{isRenaming ? (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onBlur={() => onFinishRename()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') onFinishRename();
|
|
||||||
if (e.key === 'Escape') onCancelRename();
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
|
|
||||||
{chat.name ?? 'New chat'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{trailing && (
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
|
|
||||||
)}
|
|
||||||
{actions && (
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
|
|
||||||
{meta.join(' · ')}
|
|
||||||
</div>
|
|
||||||
{preview && (
|
|
||||||
<div className="ml-5 text-xs italic text-muted-foreground truncate">
|
|
||||||
{preview}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!showContextMenu) return inner;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
|
|
||||||
Delete
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionLandingPage({
|
export function SessionLandingPage({
|
||||||
chats,
|
|
||||||
onOpenChat,
|
|
||||||
onSend,
|
|
||||||
projectId,
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
agentId,
|
||||||
|
onAgentChange,
|
||||||
|
onSend,
|
||||||
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
onReopenChat,
|
|
||||||
onArchiveChat,
|
|
||||||
onRenameChat,
|
|
||||||
onDeleteChat,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [composerValue, setComposerValue] = useState('');
|
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
|
|
||||||
|
|
||||||
const openChats = chats
|
|
||||||
.filter((c) => c.status === 'open')
|
|
||||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
||||||
const archivedChats = chats
|
|
||||||
.filter((c) => c.status === 'archived')
|
|
||||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
||||||
|
|
||||||
// Create a chat lazily on first send or slash command.
|
|
||||||
const ensureChat = useCallback(async (): Promise<string> => {
|
const ensureChat = useCallback(async (): Promise<string> => {
|
||||||
if (chatId) return chatId;
|
if (chatId) return chatId;
|
||||||
try {
|
try {
|
||||||
@@ -192,207 +38,45 @@ export function SessionLandingPage({
|
|||||||
}
|
}
|
||||||
}, [chatId, createChat]);
|
}, [chatId, createChat]);
|
||||||
|
|
||||||
async function handleSend() {
|
const handleSend = useCallback(async (content: string) => {
|
||||||
const text = composerValue.trim();
|
const text = content.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
const cid = await ensureChat();
|
await ensureChat();
|
||||||
onSend(text);
|
onSend(text);
|
||||||
setComposerValue('');
|
|
||||||
} catch {
|
} catch {
|
||||||
// Error already surfaced via toast.
|
// Error already surfaced via toast.
|
||||||
}
|
}
|
||||||
}
|
}, [ensureChat, onSend]);
|
||||||
|
|
||||||
// v2.3: slash-command dispatch on landing page. Creates a chat first if
|
// Route to the parent, which creates the chat, assigns it to the pane (so the
|
||||||
// one doesn't exist, then invokes the skill on that chat.
|
// pane transitions to ChatPane and subscribes to the stream), then invokes the
|
||||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
// skill — mirroring the text-send transition. Doing the skill invoke locally
|
||||||
try {
|
// (without the pane assignment) left the landing pane stuck/blank.
|
||||||
const cid = await ensureChat();
|
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
|
||||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
setComposerValue('');
|
}, [onSkillInvoke]);
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
|
||||||
}
|
|
||||||
}, [ensureChat]);
|
|
||||||
|
|
||||||
function startRename(chat: Chat) {
|
|
||||||
setRenamingId(chat.id);
|
|
||||||
setRenameValue(chat.name ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishRename() {
|
|
||||||
if (renamingId && renameValue.trim()) {
|
|
||||||
await onRenameChat(renamingId, renameValue.trim());
|
|
||||||
}
|
|
||||||
setRenamingId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Landing page chat counts are a snapshot at mount. New messages in
|
|
||||||
// visible chats won't update the per-row stats until next mount/navigation.
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<div className="flex-1 flex items-center justify-center px-6">
|
||||||
{openChats.length > 0 && (
|
<p className="text-sm text-muted-foreground">
|
||||||
<div>
|
Send a message to start.
|
||||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
</p>
|
||||||
<ul className="divide-y rounded-md border">
|
|
||||||
{openChats.map((chat) => (
|
|
||||||
<li key={chat.id}>
|
|
||||||
<ChatRow
|
|
||||||
chat={chat}
|
|
||||||
onClick={() => onOpenChat(chat.id)}
|
|
||||||
renamingId={renamingId}
|
|
||||||
renameValue={renameValue}
|
|
||||||
setRenameValue={setRenameValue}
|
|
||||||
onFinishRename={() => void finishRename()}
|
|
||||||
onCancelRename={() => setRenamingId(null)}
|
|
||||||
onContextStartRename={() => startRename(chat)}
|
|
||||||
onContextArchive={() => setArchiveConfirm(chat)}
|
|
||||||
onContextDelete={() => setDeleteConfirm(chat)}
|
|
||||||
showContextMenu
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Archive chat"
|
|
||||||
title="Archive chat"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setArchiveConfirm(chat);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Archive size={14} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Delete chat"
|
|
||||||
title="Delete chat"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDeleteConfirm(chat);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{archivedChats.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
|
||||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
||||||
Archived chats ({archivedChats.length})
|
|
||||||
</button>
|
|
||||||
{showArchived && (
|
|
||||||
<ul className="divide-y rounded-md border">
|
|
||||||
{archivedChats.map((chat) => (
|
|
||||||
<li key={chat.id}>
|
|
||||||
<ChatRow
|
|
||||||
chat={chat}
|
|
||||||
onClick={() => void onReopenChat(chat.id)}
|
|
||||||
dimmed
|
|
||||||
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
|
|
||||||
renamingId={null}
|
|
||||||
renameValue=""
|
|
||||||
setRenameValue={() => {}}
|
|
||||||
onFinishRename={() => {}}
|
|
||||||
onCancelRename={() => {}}
|
|
||||||
onContextStartRename={() => {}}
|
|
||||||
onContextArchive={() => {}}
|
|
||||||
onContextDelete={() => {}}
|
|
||||||
showContextMenu={false}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{openChats.length === 0 && archivedChats.length === 0 && (
|
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
|
||||||
No chats yet. Type below to start a conversation.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChatInput
|
||||||
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
|
disabled={false}
|
||||||
chatId is created lazily on first send/slash. */}
|
projectId={projectId}
|
||||||
<div className="border-t px-4 py-3 shrink-0">
|
sessionId={sessionId}
|
||||||
<ChatInput
|
agentId={agentId ?? null}
|
||||||
projectId={projectId}
|
onAgentChange={onAgentChange}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
chatLabel={chatId ? undefined : 'Chat'}
|
chatLabel="Chat"
|
||||||
disabled={false}
|
messages={[]}
|
||||||
/>
|
modelContextLimit={null}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Archive chat?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
|
||||||
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
|
|
||||||
setArchiveConfirm(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete chat?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Permanently delete{' '}
|
|
||||||
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
|
|
||||||
{' '}and all its messages. This cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { CSSProperties, RefObject } from 'react';
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -8,9 +8,19 @@ export interface SlashCommandItem {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandGroup {
|
||||||
|
label: string;
|
||||||
|
items: SlashCommandItem[];
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: string;
|
query: string;
|
||||||
items: SlashCommandItem[];
|
items: SlashCommandItem[];
|
||||||
|
// Optional segmented rendering. When provided, items are shown under labeled
|
||||||
|
// group headers (in order). `items` is ignored. BooChat passes only `items`
|
||||||
|
// (flat) so its menu is unchanged — grouping is opt-in.
|
||||||
|
groups?: SlashCommandGroup[];
|
||||||
inputRef: RefObject<HTMLElement | null>;
|
inputRef: RefObject<HTMLElement | null>;
|
||||||
onSelect: (name: string) => void;
|
onSelect: (name: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,6 +38,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
|
|||||||
export function SlashCommandPicker({
|
export function SlashCommandPicker({
|
||||||
query,
|
query,
|
||||||
items,
|
items,
|
||||||
|
groups,
|
||||||
inputRef,
|
inputRef,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -35,7 +46,21 @@ export function SlashCommandPicker({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
// When grouped, filter each group and drop empties; otherwise the flat list.
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
groups
|
||||||
|
? groups
|
||||||
|
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
: null,
|
||||||
|
[groups, query],
|
||||||
|
);
|
||||||
|
// Flat list drives keyboard nav + Enter selection across all groups.
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
|
||||||
|
[filteredGroups, items, query],
|
||||||
|
);
|
||||||
|
|
||||||
const [rect, setRect] = useState<DOMRect | null>(
|
const [rect, setRect] = useState<DOMRect | null>(
|
||||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||||
@@ -130,6 +155,36 @@ export function SlashCommandPicker({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [rect, vvTick]);
|
}, [rect, vvTick]);
|
||||||
|
|
||||||
|
const renderItem = (item: SlashCommandItem, i: number) => (
|
||||||
|
<div
|
||||||
|
key={`${i}-${item.name}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIndex}
|
||||||
|
data-highlighted={i === highlightIndex}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||||
|
i === highlightIndex && 'bg-muted',
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
onClick={() => onSelect(item.name)}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||||
|
{item.description && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground overflow-hidden"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let runningIndex = -1;
|
||||||
const popover = filtered.length === 0 ? (
|
const popover = filtered.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -146,34 +201,17 @@ export function SlashCommandPicker({
|
|||||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{filtered.map((item, i) => (
|
{filteredGroups
|
||||||
<div
|
? filteredGroups.map((g) => (
|
||||||
key={item.name}
|
<div key={g.label}>
|
||||||
role="option"
|
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
|
||||||
aria-selected={i === highlightIndex}
|
{g.icon}
|
||||||
data-highlighted={i === highlightIndex}
|
{g.label}
|
||||||
className={cn(
|
</div>
|
||||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||||
i === highlightIndex && 'bg-muted',
|
|
||||||
)}
|
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
|
||||||
onClick={() => onSelect(item.name)}
|
|
||||||
>
|
|
||||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
|
||||||
{item.description && (
|
|
||||||
<div
|
|
||||||
className="text-xs text-muted-foreground overflow-hidden"
|
|
||||||
style={{
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.description}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
))
|
||||||
</div>
|
: filtered.map((item, i) => renderItem(item, i))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface Props {
|
|||||||
project: Project | null;
|
project: Project | null;
|
||||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
|
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspace({
|
export function Workspace({
|
||||||
@@ -48,6 +49,7 @@ export function Workspace({
|
|||||||
chatsHook,
|
chatsHook,
|
||||||
session,
|
session,
|
||||||
project,
|
project,
|
||||||
|
onCoderConnectedChange,
|
||||||
onAddPane,
|
onAddPane,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
@@ -80,6 +82,7 @@ export function Workspace({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
} = chatsHook;
|
} = chatsHook;
|
||||||
|
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
@@ -141,6 +144,7 @@ export function Workspace({
|
|||||||
|
|
||||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||||
|
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
@@ -212,24 +216,23 @@ export function Workspace({
|
|||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCoder && (
|
{isCoder && !isMobile && (
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
||||||
<Code size={12} className="text-muted-foreground" />
|
<Code size={12} className="text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
<span className="text-xs text-muted-foreground">BooCode</span>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
aria-label="New pane"
|
aria-label="New pane"
|
||||||
title="New pane"
|
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -241,23 +244,12 @@ export function Workspace({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
|
||||||
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
|
|
||||||
)}
|
|
||||||
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
|
|
||||||
/>
|
|
||||||
{panes.length > 1 && (
|
{panes.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
|
||||||
e.stopPropagation();
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
removePane(idx);
|
aria-label="Close pane"
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
|
||||||
aria-label="Close BooCode pane"
|
|
||||||
title="Close BooCode pane"
|
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -283,7 +275,7 @@ export function Workspace({
|
|||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -354,9 +346,15 @@ export function Workspace({
|
|||||||
chatId={activePaneChatId(pane)}
|
chatId={activePaneChatId(pane)}
|
||||||
chatPending={isPaneChatPending(pane.id)}
|
chatPending={isPaneChatPending(pane.id)}
|
||||||
projectPath={project?.path}
|
projectPath={project?.path}
|
||||||
onConnectedChange={(connected) =>
|
onConnectedChange={(connected) => {
|
||||||
setCoderConnected((prev) =>
|
setCoderConnected((prev) =>
|
||||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
||||||
|
);
|
||||||
|
onCoderConnectedChange?.(pane.id, connected);
|
||||||
|
}}
|
||||||
|
onAgentLabelChange={(label) =>
|
||||||
|
setCoderLabels((prev) =>
|
||||||
|
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -384,19 +382,13 @@ export function Workspace({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SessionLandingPage
|
<SessionLandingPage
|
||||||
sessionId={sessionId}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
chats={chats}
|
sessionId={sessionId}
|
||||||
|
agentId={agentId}
|
||||||
|
onAgentChange={onAgentChange}
|
||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onReopenChat={async (chatId) => {
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
await unarchiveChat(chatId);
|
|
||||||
openChatInPane(idx, chatId);
|
|
||||||
}}
|
|
||||||
onArchiveChat={archiveChat}
|
|
||||||
onRenameChat={renameChat}
|
|
||||||
onDeleteChat={deleteChat}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaudeIcon({ size = 14, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
|
||||||
|
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeIcon({ size = 14, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="96 64 288 384" fill="currentColor" className={className}>
|
||||||
|
<path d="M320 224V352H192V224H320Z" opacity={0.4} />
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
|
||||||
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
||||||
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
||||||
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
||||||
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
|
||||||
export interface CoderMessageWire {
|
export interface CoderMessageWire {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
|
|
||||||
const isUser = message.role === 'user';
|
|
||||||
const isStreaming = message.status === 'streaming';
|
|
||||||
const hasText = message.content.trim().length > 0;
|
|
||||||
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
|
|
||||||
|
|
||||||
if (isUser) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{hasReasoning && (
|
|
||||||
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
|
|
||||||
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
|
|
||||||
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
|
|
||||||
{message.reasoning_text}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
{(hasText || (isStreaming && !hasReasoning)) && (
|
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
|
||||||
{hasText ? <MarkdownRenderer content={message.content} /> : null}
|
|
||||||
{isStreaming && (
|
|
||||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{message.status === 'failed' && (
|
|
||||||
<div className="text-xs text-destructive">message failed</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: CoderTimelineWire[];
|
messages: CoderTimelineWire[];
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
actions?: MessageActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoderMessageList({ messages, chatId, footer }: Props) {
|
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
||||||
|
|
||||||
|
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
@@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
|
|||||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||||
{renderItems.map((item) => {
|
{renderItems.map((item) => {
|
||||||
if (item.kind === 'message') {
|
if (item.kind === 'message') {
|
||||||
return <CoderTextBubble key={item.message.id} message={item.message} />;
|
return (
|
||||||
|
<MessageBubble
|
||||||
|
key={item.message.id}
|
||||||
|
message={item.message as unknown as Message}
|
||||||
|
actions={actions}
|
||||||
|
hideActions={CODER_HIDDEN_ACTIONS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (item.kind === 'tool_run') {
|
if (item.kind === 'tool_run') {
|
||||||
if (item.run.call.name === 'ask_user_input' && chatId) {
|
if (item.run.call.name === 'ask_user_input' && chatId) {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
|
||||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||||
import { PermissionCard } from '@/components/PermissionCard';
|
import { PermissionCard } from '@/components/PermissionCard';
|
||||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
@@ -32,6 +32,8 @@ interface CoderMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
function: { name: string; arguments: string };
|
function: { name: string; arguments: string };
|
||||||
}>;
|
}>;
|
||||||
|
ctx_used?: number | null;
|
||||||
|
ctx_max?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoderToolMessage {
|
interface CoderToolMessage {
|
||||||
@@ -63,6 +65,7 @@ interface Props {
|
|||||||
chatPending?: boolean;
|
chatPending?: boolean;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
onConnectedChange?: (connected: boolean) => void;
|
onConnectedChange?: (connected: boolean) => void;
|
||||||
|
onAgentLabelChange?: (label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WsHandlers {
|
interface WsHandlers {
|
||||||
@@ -91,6 +94,8 @@ type RawCoderMessage = {
|
|||||||
| { id: string; name: string; args?: Record<string, unknown> }
|
| { id: string; name: string; args?: Record<string, unknown> }
|
||||||
| { id: string; function: { name: string; arguments: string } }
|
| { id: string; function: { name: string; arguments: string } }
|
||||||
> | null;
|
> | null;
|
||||||
|
ctx_used?: number | null;
|
||||||
|
ctx_max?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||||
@@ -126,6 +131,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
|
|||||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||||
...(reasoning_text ? { reasoning_text } : {}),
|
...(reasoning_text ? { reasoning_text } : {}),
|
||||||
...(tool_calls?.length ? { tool_calls } : {}),
|
...(tool_calls?.length ? { tool_calls } : {}),
|
||||||
|
ctx_used: raw.ctx_used ?? null,
|
||||||
|
ctx_max: raw.ctx_max ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +235,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
|||||||
);
|
);
|
||||||
const next = prev.map((m) =>
|
const next = prev.map((m) =>
|
||||||
m.id === frame.message_id && m.role !== 'tool'
|
m.id === frame.message_id && m.role !== 'tool'
|
||||||
? { ...m, status: 'complete' as const }
|
? {
|
||||||
|
...m,
|
||||||
|
status: 'complete' as const,
|
||||||
|
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
||||||
|
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
||||||
|
}
|
||||||
: m,
|
: m,
|
||||||
);
|
);
|
||||||
if (completed) {
|
if (completed) {
|
||||||
@@ -343,7 +355,7 @@ function usePendingChanges(sessionId: string) {
|
|||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const approve = useCallback(async (changeId: string) => {
|
const approve = useCallback(async (changeId: string) => {
|
||||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
|
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -352,7 +364,7 @@ function usePendingChanges(sessionId: string) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const reject = useCallback(async (changeId: string) => {
|
const reject = useCallback(async (changeId: string) => {
|
||||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
|
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -463,6 +475,7 @@ export function CoderPane({
|
|||||||
chatPending = false,
|
chatPending = false,
|
||||||
projectPath,
|
projectPath,
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
|
onAgentLabelChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||||
provider: 'boocode',
|
provider: 'boocode',
|
||||||
@@ -470,6 +483,12 @@ export function CoderPane({
|
|||||||
modeId: null,
|
modeId: null,
|
||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parts = [agentConfig.provider || 'boocode'];
|
||||||
|
if (agentConfig.model) parts.push(agentConfig.model);
|
||||||
|
onAgentLabelChange?.(parts.join(' · '));
|
||||||
|
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
|
||||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||||
@@ -492,6 +511,50 @@ export function CoderPane({
|
|||||||
[displayedCommands],
|
[displayedCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v2.5.9: segmented slash menu — the active agent's commands first, then
|
||||||
|
// BooCoder skills. boocode has no separate "commands" group (it IS native),
|
||||||
|
// so it shows only Skills. Empty groups are dropped.
|
||||||
|
const agentCommands = useMemo(
|
||||||
|
() =>
|
||||||
|
agentConfig.provider === 'boocode'
|
||||||
|
? []
|
||||||
|
: mergeCommandsByName(providerCommands, liveTaskCommands),
|
||||||
|
[agentConfig.provider, providerCommands, liveTaskCommands],
|
||||||
|
);
|
||||||
|
const skillItems = useMemo(
|
||||||
|
() => skills.map((s) => ({ name: s.name, description: s.description })),
|
||||||
|
[skills],
|
||||||
|
);
|
||||||
|
const slashGroups = useMemo(() => {
|
||||||
|
const groups: SlashCommandGroup[] = [];
|
||||||
|
// Split the active agent's set: native/CLI commands vs plugin skills, each
|
||||||
|
// with its own icon. BooCoder skills always come last.
|
||||||
|
const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
|
||||||
|
const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
|
||||||
|
if (agentCmds.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: `${agentConfig.provider} commands`,
|
||||||
|
items: agentCmds,
|
||||||
|
icon: <Terminal className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agentSkills.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: `${agentConfig.provider} skills`,
|
||||||
|
items: agentSkills,
|
||||||
|
icon: <Puzzle className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (skillItems.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: 'BooCoder skills',
|
||||||
|
items: skillItems,
|
||||||
|
icon: <Sparkles className="size-3 shrink-0" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [agentCommands, skillItems, agentConfig.provider]);
|
||||||
|
|
||||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
onPermissionRequested: (prompt) => {
|
onPermissionRequested: (prompt) => {
|
||||||
@@ -515,6 +578,8 @@ export function CoderPane({
|
|||||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
|
const queueProcessing = useRef(false);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// Refresh pending changes when a message_complete arrives
|
// Refresh pending changes when a message_complete arrives
|
||||||
@@ -658,43 +723,103 @@ export function CoderPane({
|
|||||||
setMessages,
|
setMessages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSlashSelect = useCallback((name: string) => {
|
|
||||||
const next = `/${name} `;
|
|
||||||
setInput(next);
|
|
||||||
setSlashState(null);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const ta = inputRef.current;
|
|
||||||
if (ta) {
|
|
||||||
ta.selectionStart = ta.selectionEnd = next.length;
|
|
||||||
ta.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const sendOneMessage = useCallback(async (text: string) => {
|
||||||
const newValue = e.target.value;
|
if (!chatId) return;
|
||||||
setInput(newValue);
|
setSending(true);
|
||||||
if (isSlashCommandToken(newValue)) {
|
setPermissionPrompt(null);
|
||||||
setSlashState({ query: slashQuery(newValue) });
|
setLiveTaskCommands([]);
|
||||||
} else {
|
|
||||||
setSlashState(null);
|
const tempId = `temp-${Date.now()}`;
|
||||||
|
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.coder.sendMessage(sessionId, {
|
||||||
|
content: text,
|
||||||
|
pane_id: paneId,
|
||||||
|
chat_id: chatId,
|
||||||
|
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
|
||||||
|
model: agentConfig.model || undefined,
|
||||||
|
mode_id: agentConfig.modeId ?? undefined,
|
||||||
|
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||||
|
});
|
||||||
|
if (data.user_message_id) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.task_id) {
|
||||||
|
setActiveTaskId(data.task_id);
|
||||||
|
} else {
|
||||||
|
setActiveTaskId(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
// Drain queue when not busy
|
||||||
(e: React.KeyboardEvent) => {
|
useEffect(() => {
|
||||||
if (slashState) return;
|
if (sending || queue.length === 0 || queueProcessing.current) return;
|
||||||
if (e.nativeEvent.isComposing) return;
|
queueProcessing.current = true;
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
const next = queue[0]!;
|
||||||
e.preventDefault();
|
setQueue((prev) => prev.slice(1));
|
||||||
void handleSend();
|
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
||||||
}
|
}, [sending, queue, sendOneMessage]);
|
||||||
},
|
|
||||||
[handleSend, slashState]
|
const handleChatInputSend = useCallback(async (content: string) => {
|
||||||
);
|
const text = content.trim();
|
||||||
|
if (!text || !chatId) return;
|
||||||
|
if (sending) {
|
||||||
|
setQueue((prev) => [...prev, text]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendOneMessage(text);
|
||||||
|
}, [sending, chatId, sendOneMessage]);
|
||||||
|
|
||||||
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
|
if (!chatId) return;
|
||||||
|
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||||
|
// through to a literal send in ChatInput. Skills run under the active
|
||||||
|
// provider: boocode → native inference; external → body injected into a task.
|
||||||
|
if (!skillsByName.has(skillName)) return;
|
||||||
|
setSending(true);
|
||||||
|
setPermissionPrompt(null);
|
||||||
|
setLiveTaskCommands([]);
|
||||||
|
try {
|
||||||
|
const data = await api.coder.skillInvoke(
|
||||||
|
sessionId,
|
||||||
|
paneId,
|
||||||
|
skillName,
|
||||||
|
userMessage.length > 0 ? userMessage : null,
|
||||||
|
agentConfig.provider !== 'boocode'
|
||||||
|
? {
|
||||||
|
provider: agentConfig.provider,
|
||||||
|
model: agentConfig.model || undefined,
|
||||||
|
mode_id: agentConfig.modeId ?? undefined,
|
||||||
|
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
if (data.task_id) setActiveTaskId(data.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<AgentComposerBar
|
||||||
|
projectPath={projectPath}
|
||||||
|
value={agentConfig}
|
||||||
|
onChange={setAgentConfig}
|
||||||
|
onProviderCommandsChange={handleProviderCommandsChange}
|
||||||
|
connected={connected}
|
||||||
|
/>
|
||||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
@@ -706,6 +831,9 @@ export function CoderPane({
|
|||||||
<CoderMessageList
|
<CoderMessageList
|
||||||
messages={messages as CoderTimelineWire[]}
|
messages={messages as CoderTimelineWire[]}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
actions={{
|
||||||
|
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
activeTaskId && !permissionPrompt && sending === false ? (
|
activeTaskId && !permissionPrompt && sending === false ? (
|
||||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||||
@@ -738,44 +866,17 @@ export function CoderPane({
|
|||||||
|
|
||||||
{/* Composer + input */}
|
{/* Composer + input */}
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="shrink-0 border-t border-border">
|
||||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
<ChatInput
|
||||||
<AgentComposerBar
|
disabled={sending || !chatId || chatPending}
|
||||||
projectPath={projectPath}
|
projectId={projectPath ?? ''}
|
||||||
value={agentConfig}
|
onSend={handleChatInputSend}
|
||||||
onChange={setAgentConfig}
|
onSlashCommand={handleChatInputSlash}
|
||||||
onProviderCommandsChange={handleProviderCommandsChange}
|
slashGroups={slashGroups}
|
||||||
|
chatId={chatId ?? undefined}
|
||||||
|
chatLabel="BooCode"
|
||||||
|
messages={messages as unknown as import('@/api/types').Message[]}
|
||||||
|
modelContextLimit={null}
|
||||||
/>
|
/>
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<textarea
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type / for commands…"
|
|
||||||
rows={1}
|
|
||||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSend()}
|
|
||||||
disabled={!input.trim() || sending || !chatId || chatPending}
|
|
||||||
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
||||||
aria-label="Send message"
|
|
||||||
>
|
|
||||||
<Send size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{slashState && (
|
|
||||||
<SlashCommandPicker
|
|
||||||
query={slashState.query}
|
|
||||||
items={displayedCommands}
|
|
||||||
inputRef={inputRef}
|
|
||||||
onSelect={handleSlashSelect}
|
|
||||||
onClose={() => setSlashState(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
|
|||||||
reason?: ErrorReason;
|
reason?: ErrorReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefetchMessagesEvent {
|
||||||
|
type: 'refetch_messages';
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionEvent =
|
export type SessionEvent =
|
||||||
| SessionRenamedEvent
|
| SessionRenamedEvent
|
||||||
| ProjectCreatedEvent
|
| ProjectCreatedEvent
|
||||||
@@ -186,7 +190,8 @@ export type SessionEvent =
|
|||||||
| ProjectArchivedEvent
|
| ProjectArchivedEvent
|
||||||
| ProjectUnarchivedEvent
|
| ProjectUnarchivedEvent
|
||||||
| ProjectUpdatedEvent
|
| ProjectUpdatedEvent
|
||||||
| ChatStatusEvent;
|
| ChatStatusEvent
|
||||||
|
| RefetchMessagesEvent;
|
||||||
type Listener = (event: SessionEvent) => void;
|
type Listener = (event: SessionEvent) => void;
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
const listeners = new Set<Listener>();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
|
|||||||
deleteChat: (chatId: string) => Promise<void>;
|
deleteChat: (chatId: string) => Promise<void>;
|
||||||
renameChat: (chatId: string, name: string) => Promise<void>;
|
renameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
||||||
|
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionChats(
|
export function useSessionChats(
|
||||||
@@ -166,6 +167,25 @@ export function useSessionChats(
|
|||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Slash-command equivalent of handleLandingSend: the initial (landing) chat
|
||||||
|
// must create the chat AND assign it to the pane (openChatInPane) before
|
||||||
|
// invoking the skill, so the pane transitions to ChatPane and subscribes to
|
||||||
|
// the chat's stream. Skipping the assignment left the pane stuck on the
|
||||||
|
// landing page while the skill ran invisibly (and could blank the pane).
|
||||||
|
const handleLandingSkill = useCallback(
|
||||||
|
async (paneIdx: number, skillName: string, userMessage: string | null) => {
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.create(sessionId);
|
||||||
|
setChats((prev) => (prev.some((c) => c.id === chat.id) ? prev : [chat, ...prev]));
|
||||||
|
openChatInPaneRef.current(paneIdx, chat.id);
|
||||||
|
await api.chats.skillInvoke(chat.id, skillName, userMessage);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chats,
|
chats,
|
||||||
setChats,
|
setChats,
|
||||||
@@ -175,5 +195,6 @@ export function useSessionChats(
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
|
|||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
return sessionEvents.subscribe((event) => {
|
||||||
|
if (event.type === 'refetch_messages') {
|
||||||
|
void api.messages
|
||||||
|
.list(sessionId)
|
||||||
|
.then((messages) => {
|
||||||
|
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('refetch_messages failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'chat_unarchived':
|
case 'chat_unarchived':
|
||||||
case 'chat_deleted':
|
case 'chat_deleted':
|
||||||
case 'chat_status':
|
case 'chat_status':
|
||||||
|
case 'refetch_messages':
|
||||||
return prev;
|
return prev;
|
||||||
case 'project_archived': {
|
case 'project_archived': {
|
||||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useViewport } from './useViewport';
|
||||||
|
|
||||||
interface SidebarDrawerState {
|
interface SidebarDrawerState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
|
|||||||
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
|
||||||
// Auto-close on navigation. Effect fires once on mount too (open default
|
|
||||||
// is false, so no observable effect) and on every pathname change after.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// Close drawer on orientation change (landscape→portrait transition).
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const toggle = useCallback(() => setOpen((v) => !v), []);
|
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||||
|
|
||||||
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||||
|
|||||||
@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
||||||
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
|
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
|
||||||
const update = () => setState(snapshot());
|
const update = () =>
|
||||||
|
setState((prev) => {
|
||||||
|
const next = snapshot();
|
||||||
|
// Bail if nothing changed — visualViewport 'resize' fires on every
|
||||||
|
// URL-bar show/hide and scroll, and a fresh object would re-render
|
||||||
|
// every consumer needlessly.
|
||||||
|
if (
|
||||||
|
prev.isMobile === next.isMobile &&
|
||||||
|
prev.isTablet === next.isTablet &&
|
||||||
|
prev.width === next.width
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a
|
||||||
|
// backgrounded tab is restored (bfcache) or refocused, no 'change' fires,
|
||||||
|
// and the width captured at first paint can be a stale/oversized value
|
||||||
|
// (iOS reports the wrong innerWidth for a beat before layout settles). That
|
||||||
|
// leaves isMobile=false on a phone, so the sidebar renders as a permanent
|
||||||
|
// desktop column with no way to close it. Re-snapshot on every signal that
|
||||||
|
// accompanies a rejoin/viewport correction, not just breakpoint crossings.
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') update();
|
||||||
|
};
|
||||||
mobileMq.addEventListener('change', update);
|
mobileMq.addEventListener('change', update);
|
||||||
tabletMq.addEventListener('change', update);
|
tabletMq.addEventListener('change', update);
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
window.addEventListener('orientationchange', update);
|
||||||
|
window.addEventListener('pageshow', update);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
window.visualViewport?.addEventListener('resize', update);
|
||||||
update();
|
update();
|
||||||
return () => {
|
return () => {
|
||||||
mobileMq.removeEventListener('change', update);
|
mobileMq.removeEventListener('change', update);
|
||||||
tabletMq.removeEventListener('change', update);
|
tabletMq.removeEventListener('change', update);
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('orientationchange', update);
|
||||||
|
window.removeEventListener('pageshow', update);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
window.visualViewport?.removeEventListener('resize', update);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
|
|||||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||||
if (attachments.length === 0) return text;
|
if (attachments.length === 0) return text;
|
||||||
const blocks = attachments.map(a => {
|
const blocks = attachments.map(a => {
|
||||||
const fence = '```' + (a.language ?? '');
|
// Pasted text is raw context, not code from a file — insert it verbatim with
|
||||||
let header: string;
|
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
||||||
if (a.kind === 'lines') {
|
// tidy while composing; on send it should be exactly what the user pasted.
|
||||||
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
|
if (a.kind === 'paste') {
|
||||||
} else if (a.kind === 'paste') {
|
return a.content;
|
||||||
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
|
|
||||||
} else {
|
|
||||||
header = `// from: ${a.filename}`;
|
|
||||||
}
|
}
|
||||||
|
const fence = '```' + (a.language ?? '');
|
||||||
|
const header =
|
||||||
|
a.kind === 'lines'
|
||||||
|
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
||||||
|
: `// from: ${a.filename}`;
|
||||||
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
||||||
});
|
});
|
||||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
return [...blocks, text].filter(Boolean).join('\n\n');
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
|
|||||||
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
|
export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
|
||||||
const byName = new Map<string, SlashCommandItem>();
|
const byName = new Map<string, T>();
|
||||||
for (const list of lists) {
|
for (const list of lists) {
|
||||||
for (const cmd of list) {
|
for (const cmd of list) {
|
||||||
byName.set(cmd.name, cmd);
|
byName.set(cmd.name, cmd);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
validatePanes,
|
validatePanes,
|
||||||
} = panesHook;
|
} = panesHook;
|
||||||
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
const activeIsCoder = activePane?.kind === 'coder';
|
||||||
|
|
||||||
const openChatInActivePane = useCallback(
|
const openChatInActivePane = useCallback(
|
||||||
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
||||||
@@ -402,6 +405,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
onAddPane={addPaneAndSwitch}
|
onAddPane={addPaneAndSwitch}
|
||||||
disabled={panes.length >= MAX_PANES}
|
disabled={panes.length >= MAX_PANES}
|
||||||
/>
|
/>
|
||||||
|
{activeIsCoder && activePane && panes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePane(activePaneIdx)}
|
||||||
|
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
|
||||||
|
aria-label="Close pane"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -495,6 +508,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
session={session}
|
session={session}
|
||||||
project={project}
|
project={project}
|
||||||
onAddPane={addPaneAndSwitch}
|
onAddPane={addPaneAndSwitch}
|
||||||
|
onCoderConnectedChange={(paneId, connected) =>
|
||||||
|
setCoderConnected((prev) =>
|
||||||
|
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ WORKDIR /build
|
|||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base
|
RUN apk add --no-cache git ca-certificates build-base
|
||||||
|
|
||||||
# Build codecontext from the v3.2.1 tag.
|
# Build codecontext from the boocode-ts fork (has .codecontextignore support).
|
||||||
|
# Source is staged into the build context by the pre-build step:
|
||||||
|
# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext .
|
||||||
# CGO is required: codecontext binds tree-sitter via cgo.
|
# CGO is required: codecontext binds tree-sitter via cgo.
|
||||||
RUN git clone --depth=1 --branch v3.2.1 https://github.com/nmakod/codecontext.git /build/codecontext
|
COPY fork.tar.gz /build/fork.tar.gz
|
||||||
|
RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /build/codecontext
|
||||||
WORKDIR /build/codecontext
|
WORKDIR /build/codecontext
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func startChild() error {
|
|||||||
// initial scan target — codecontext rebuilds the graph against whatever
|
// initial scan target — codecontext rebuilds the graph against whatever
|
||||||
// target_dir each call carries, so this is just a valid bootstrap path
|
// target_dir each call carries, so this is just a valid bootstrap path
|
||||||
// (the default "." is the alpine root and trips on transient /proc fds).
|
// (the default "." is the alpine root and trips on transient /proc fds).
|
||||||
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true")
|
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore")
|
||||||
var err error
|
var err error
|
||||||
childStdin, err = child.StdinPipe()
|
childStdin, err = child.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||||
---
|
---
|
||||||
You review code. Find real problems, not style nits.
|
You review code. Find real problems, not style nits.
|
||||||
@@ -46,7 +46,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||||
---
|
---
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||||
@@ -72,7 +72,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
steps: 5
|
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
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.
|
||||||
---
|
---
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||||
@@ -115,7 +115,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 1.5
|
presence_penalty: 1.5
|
||||||
steps: 20
|
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
You design. You produce build plans, not code.
|
You design. You produce build plans, not code.
|
||||||
@@ -157,7 +157,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Audits code for security vulnerabilities. Read-only.
|
description: Audits code for security vulnerabilities. Read-only.
|
||||||
---
|
---
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
You audit for security issues. Concrete findings only, no generic warnings.
|
||||||
@@ -240,7 +240,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||||
---
|
---
|
||||||
You map codebases. Start broad, then drill into specifics.
|
You map codebases. Start broad, then drill into specifics.
|
||||||
@@ -258,3 +258,67 @@ Output:
|
|||||||
- Data flow map (entry → transform → output)
|
- Data flow map (entry → transform → output)
|
||||||
- Conventions observed
|
- Conventions observed
|
||||||
- Areas that need deeper investigation
|
- Areas that need deeper investigation
|
||||||
|
|
||||||
|
|
||||||
|
## Planner
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
top_p: 0.95
|
||||||
|
top_k: 20
|
||||||
|
min_p: 0.0
|
||||||
|
presence_penalty: 0.0
|
||||||
|
steps: 10
|
||||||
|
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||||
|
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||||
|
---
|
||||||
|
You produce actionable step plans. You do not modify files.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Restate the goal. Confirm scope and constraints.
|
||||||
|
2. Read the relevant code areas with view_file, list_dir, grep. Understand the current state before planning.
|
||||||
|
3. Identify dependencies between steps. Order them so each step has its prerequisites met.
|
||||||
|
4. Estimate complexity per step (small / medium / large).
|
||||||
|
5. Call out risks and assumptions that could invalidate the plan.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Goal: one line
|
||||||
|
- Prerequisites: what must be true before starting
|
||||||
|
- Steps: numbered, each with file paths, what changes, and acceptance criteria
|
||||||
|
- Risks: what could go wrong, how to detect it
|
||||||
|
- Verification: how to confirm the plan succeeded (test commands, type checks, manual checks)
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Every step must be independently verifiable.
|
||||||
|
- Do not produce code. Describe what to change, not the change itself.
|
||||||
|
- If a step affects more than 3 files, break it into sub-steps.
|
||||||
|
- Flag any step that requires a database migration or env var change.
|
||||||
|
|
||||||
|
|
||||||
|
## Builder
|
||||||
|
---
|
||||||
|
temperature: 0.6
|
||||||
|
top_p: 0.95
|
||||||
|
top_k: 20
|
||||||
|
min_p: 0.0
|
||||||
|
presence_penalty: 0.0
|
||||||
|
steps: 50
|
||||||
|
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, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||||
|
description: Implements changes using read and write tools. Routes all writes through pending changes.
|
||||||
|
---
|
||||||
|
You implement. Read the code, make the changes, verify they work.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Read the target files and understand the current state.
|
||||||
|
2. Use grep and get_dependencies to find all call sites and dependents.
|
||||||
|
3. Make changes via edit_file / create_file. All writes queue in pending_changes.
|
||||||
|
4. Review pending changes before calling apply_pending.
|
||||||
|
5. After applying, verify: read the modified files, check that the change is correct.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All file modifications go through edit_file / create_file / delete_file. Never bypass pending_changes.
|
||||||
|
- Read before writing. Understand what exists before changing it.
|
||||||
|
- Match existing code conventions: naming, imports, error handling patterns.
|
||||||
|
- One logical change per edit. Do not bundle unrelated changes.
|
||||||
|
- If a change breaks an import or type, fix it in the same batch before applying.
|
||||||
|
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
|
||||||
|
- When done, state what changed and what the user should verify (type check, test, manual check)
|
||||||
|
|||||||
3
data/coder-providers.json
Normal file
3
data/coder-providers.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"providers": {}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
BOOCODER_URL: http://100.114.205.53:9502
|
BOOCODER_URL: http://100.114.205.53:9502
|
||||||
|
LLAMA_SIDECAR_URL: http://100.101.41.16:8402
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
|
|||||||
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal file
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# v2.6 Design — Persistent agent sessions
|
||||||
|
|
||||||
|
Reference implementations: `/opt/forks/opencode` (server + SDK),
|
||||||
|
`/opt/forks/paseo` (warm ACP + opencode server-manager + reasoning dedup).
|
||||||
|
|
||||||
|
## 1. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
BooCoder (systemd host service)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ dispatcher (per-turn unit = tasks row) │
|
||||||
|
│ │ resolve backend + worktree + agent-session for the chat │
|
||||||
|
│ ▼ │
|
||||||
|
│ agent-pool ──────────────────────────────────────────────────┐ │
|
||||||
|
│ ├─ OpenCodeServerBackend (1 process, N sessions) │ │
|
||||||
|
│ │ `opencode serve` ◄── @opencode-ai/sdk ──► /event SSE │ │
|
||||||
|
│ └─ WarmAcpBackend[session] (1 stdio process per session) │ │
|
||||||
|
│ `goose acp` / `qwen --acp` ◄── ClientSideConnection │ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ broker.publishFrame (delta / reasoning_delta / tool_call) │
|
||||||
|
▼ │
|
||||||
|
web (CoderPane) — unchanged │
|
||||||
|
```
|
||||||
|
|
||||||
|
The **task row stays the per-turn unit**. What changes: instead of building a
|
||||||
|
fresh world per task, the dispatcher resolves the chat's *persistent* backend,
|
||||||
|
worktree, and agent-session, sends one prompt, streams events, diffs, and leaves
|
||||||
|
everything warm.
|
||||||
|
|
||||||
|
## 2. Backends
|
||||||
|
|
||||||
|
Common interface (`AgentBackend`):
|
||||||
|
|
||||||
|
```
|
||||||
|
interface AgentBackend {
|
||||||
|
ensureSession(sessionId, opts): Promise<AgentSessionHandle> // create-or-reuse
|
||||||
|
prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise<TurnResult>
|
||||||
|
closeSession(handle): Promise<void>
|
||||||
|
dispose(): Promise<void> // backend teardown
|
||||||
|
health(): 'up' | 'down'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`onEvent` emits the same normalized events the current `acp-dispatch.ts` produces
|
||||||
|
(`text`, `reasoning`, `tool_call`, `tool_update`) so the broker-frame publishing and
|
||||||
|
`persistExternalAgentTurn` paths are reused unchanged.
|
||||||
|
|
||||||
|
### 2a. OpenCodeServerBackend (shared HTTP server)
|
||||||
|
|
||||||
|
- **Spawn once per BooCoder process:** `opencode serve --hostname 127.0.0.1 --port <p>`
|
||||||
|
with `OPENCODE_SERVER_PASSWORD=<random-at-boot>` (verified: `serve.ts`, `network.ts`;
|
||||||
|
default port 4096, prints `opencode server listening on http://…`). Use the official
|
||||||
|
`@opencode-ai/sdk` (`createOpencodeServer` / `createOpencodeClient`) rather than
|
||||||
|
hand-rolling HTTP — it already parses the ready line and wraps routes.
|
||||||
|
- **One SSE subscription** to `GET /event`, consumed in a single read loop; events
|
||||||
|
demuxed by `properties.sessionID` → BooCode session. Reasoning arrives as
|
||||||
|
`message.part.delta` (`field: "reasoning"`) and `message.part.updated`
|
||||||
|
(`part.type: "reasoning"`); text as the `text` field; tool calls as tool parts.
|
||||||
|
- **One opencode session per BooCode chat.** `client.session.create()` once, store the
|
||||||
|
returned `id` in `agent_sessions.agent_session_id`. Per-turn: `client.session.prompt({
|
||||||
|
path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }})`. Worktree
|
||||||
|
routing via the `x-opencode-directory` header (set to the session's persistent
|
||||||
|
worktree) so the agent operates inside it.
|
||||||
|
- **Reasoning dedup (port from Paseo `opencode-agent.ts`):** track
|
||||||
|
`streamedPartKeys` of `reasoning:${partID}`; when a `message.part.updated` reasoning
|
||||||
|
part arrives whose key was already streamed via delta, drop it. Prevents the
|
||||||
|
double-thought bug (covered by Paseo's `opencode-reasoning-dedup` e2e test).
|
||||||
|
|
||||||
|
### 2b. WarmAcpBackend (goose, qwen — stdio)
|
||||||
|
|
||||||
|
- **One persistent process + ACP connection per (chat, agent)** (Paseo's
|
||||||
|
`SpawnedACPProcess`): spawn `goose acp` / `qwen --acp` once, NDJSON over stdio,
|
||||||
|
`initialize` → `session/new` once; store the ACP session id in the
|
||||||
|
`agent_sessions` row. Each turn calls `session/prompt` on the same connection;
|
||||||
|
switching away and back resumes this same connection/session. Reuses the existing `acp-dispatch.ts`
|
||||||
|
`handleSessionUpdate` switch verbatim for `agent_message_chunk` /
|
||||||
|
`agent_thought_chunk` / `tool_call*`.
|
||||||
|
- **Child lifetime is the pool's, not a request's.** Spawn detached/managed; do not
|
||||||
|
tie the process to a single dispatch's abort signal (only the in-flight `prompt`
|
||||||
|
gets the per-turn signal). Mirrors the codecontext shim rule (CLAUDE.md): supervise
|
||||||
|
the child and react to its exit, don't let a request scope kill it.
|
||||||
|
|
||||||
|
## 3. Data model
|
||||||
|
|
||||||
|
Agent switching is **free** within a chat (the picker is per-turn, not locked), so
|
||||||
|
the worktree is shared across agents but each agent keeps its own backend session.
|
||||||
|
That splits into two tables: one **shared worktree per chat**, and one **backend
|
||||||
|
session per (chat, agent)** pair.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- One shared worktree per BooCode chat. All agents used in the chat operate in it.
|
||||||
|
CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||||
|
session_id UUID PRIMARY KEY REFERENCES sessions(id),
|
||||||
|
worktree_path TEXT NOT NULL,
|
||||||
|
base_commit TEXT, -- project HEAD captured at create (diff baseline)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One backend session per (chat, agent). Resumed when the user switches back to
|
||||||
|
-- that agent, so each agent retains its own conversation memory across switches.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id),
|
||||||
|
agent TEXT NOT NULL, -- opencode | goose | qwen (native boocode needs no row)
|
||||||
|
backend TEXT NOT NULL, -- opencode_server | acp_warm
|
||||||
|
agent_session_id TEXT, -- opencode/ACP native session id (the memory handle)
|
||||||
|
server_port INTEGER, -- opencode server port (nullable)
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle', -- idle | active | crashed | closed
|
||||||
|
last_active_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
PRIMARY KEY (session_id, agent),
|
||||||
|
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server','acp_warm')),
|
||||||
|
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle','active','crashed','closed'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus one column for attribution (drives the DiffPanel badges in §9):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Which agent staged each pending change. Stamped at queue time:
|
||||||
|
-- worktree-diff path → the task's agent; native boocode write tools → 'boocode';
|
||||||
|
-- manual RightRail create (v2.5.x) → NULL (renders as "manual").
|
||||||
|
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
`tasks.worktree_path` already exists but was per-task; the persistent worktree now
|
||||||
|
lives on `session_worktrees`. `tasks` stays the per-turn record (state machine
|
||||||
|
unchanged) and gains nothing required. **Native boocode** keeps no `agent_sessions`
|
||||||
|
row — it has no warm backend; it reconstructs conversation context from the chat's
|
||||||
|
`messages` rows each turn (so it transparently sees every other agent's prior turns).
|
||||||
|
DB is the source of truth for reconnect after a BooCoder restart (the in-memory pool
|
||||||
|
rebuilds lazily from these tables on the next turn).
|
||||||
|
|
||||||
|
## 3a. Agent switching & continuity (the decided model)
|
||||||
|
|
||||||
|
Per the design review: **free switch, per-agent memory.** Concretely:
|
||||||
|
|
||||||
|
- **Picker is per-turn.** The message route already sends `provider`/`model` per
|
||||||
|
message; nothing locks a chat to one agent. v2.6 keeps that.
|
||||||
|
- **Worktree is shared.** All agents in a chat resolve the same `session_worktrees`
|
||||||
|
row, so file state carries across switches — *once applied*. (See the staging
|
||||||
|
boundary caveat below.)
|
||||||
|
- **Each agent resumes its own session.** Switching opencode → boocode → opencode
|
||||||
|
reuses opencode's stored `agent_session_id` (its memory intact), not a fresh one.
|
||||||
|
Lazy-create on first use of an agent in the chat; resume thereafter.
|
||||||
|
- **Native boocode is the universal reader.** It rebuilds from the `messages` table,
|
||||||
|
so it always sees the full transcript including other agents' turns.
|
||||||
|
- **Gap turns are NOT auto-replayed** into a resumed agent. When you return to
|
||||||
|
opencode, it sees the shared worktree + your new prompt, but did not "hear" the
|
||||||
|
boocode/goose turns in between. (A future refinement could inject a short
|
||||||
|
"changes since you last ran" preamble; out of scope for v2.6.)
|
||||||
|
- **Staging-boundary caveat (must be documented in the UI):** external agents edit
|
||||||
|
*inside the worktree*; native boocode reads/writes the *project root* via
|
||||||
|
`pending_changes`. So unapplied edits do **not** cross between a worktree agent and
|
||||||
|
native boocode — file continuity between the two only exists after apply. This is
|
||||||
|
an inherent consequence of v2.5's review-before-apply model, not a v2.6 bug.
|
||||||
|
- **No mid-turn switch.** Per-chat turns are serialized (§5); the agent is fixed for
|
||||||
|
the duration of an in-flight turn. The user can switch the picker for the *next*
|
||||||
|
turn while one is running, but it won't retarget the running turn.
|
||||||
|
|
||||||
|
## 4. Persistent worktree + incremental diff
|
||||||
|
|
||||||
|
- **Create** on the first turn of a chat (`createWorktree(projectPath, sessionId)`
|
||||||
|
— keyed by chat, not task), capturing project HEAD as `base_commit`. Persist the
|
||||||
|
`session_worktrees` row; all agents in the chat share it.
|
||||||
|
- **Reuse** every subsequent turn — no new worktree, no cleanup between turns.
|
||||||
|
- **Diff strategy (per turn):** diff the worktree against the **project HEAD baseline**
|
||||||
|
captured when the worktree was created. Each turn supersedes the prior
|
||||||
|
`pending_changes` row for that session (one accumulating unified diff, latest wins) —
|
||||||
|
mirrors how the anchored rolling summary supersedes itself. Avoids stacking N partial
|
||||||
|
diffs the user must reason about; the pending change always reflects the full current
|
||||||
|
delta of the worktree.
|
||||||
|
- **Apply** merges the worktree delta back to the project (existing `apply_pending`
|
||||||
|
path); after apply, re-baseline so the next turn's diff is relative to applied state.
|
||||||
|
- **Cleanup** on chat close/archive (new hook) and on `dispose()`; removes the
|
||||||
|
`session_worktrees` row + all `agent_sessions` rows for the chat. Orphan reaper
|
||||||
|
sweeps worktrees with no live `session_worktrees` row (extends the periodic sweeper).
|
||||||
|
|
||||||
|
## 5. Concurrency
|
||||||
|
|
||||||
|
Current dispatcher: global `running` boolean → strictly one task at a time.
|
||||||
|
Target: **per-session serialization, cross-session concurrency.**
|
||||||
|
|
||||||
|
- Replace the single `running` flag with a `Map<sessionId, Promise>` in-flight registry.
|
||||||
|
- `poll()` selects the oldest pending task whose **session has no in-flight turn**, so
|
||||||
|
two different chats run concurrently but a chat never has two turns at once (the agent
|
||||||
|
holds conversational state — overlapping prompts would corrupt it).
|
||||||
|
- The LISTEN/NOTIFY `tasks_new` fast path (v2.5.x) already triggers immediate polls;
|
||||||
|
the registry replaces the boolean guard there too.
|
||||||
|
|
||||||
|
## 6. Lifecycle & failure
|
||||||
|
|
||||||
|
- **Lazy spawn:** backend/worktree/agent-session created on first turn for a session.
|
||||||
|
- **Idle eviction:** pool evicts a backend/session after an idle TTL (e.g. 30 min);
|
||||||
|
worktree persists (DB-backed); next turn re-spawns and reattaches via stored
|
||||||
|
`agent_session_id` (opencode persists sessions on disk; ACP re-`session/new` if the
|
||||||
|
native id is gone).
|
||||||
|
- **Crash recovery:** supervise children; on exit mark `agent_sessions.status='crashed'`,
|
||||||
|
publish `chat_status='error'`, and rebuild on the next turn. opencode server crash
|
||||||
|
takes all opencode sessions down → restart server, recreate sessions.
|
||||||
|
- **Shutdown drain:** `app.addHook('onClose')` disposes the pool (close opencode server,
|
||||||
|
kill warm ACP children) after in-flight turns settle — extends the existing
|
||||||
|
dispatcher `stop()`.
|
||||||
|
- **systemd:** BooCoder already spawns agent children under `NoNewPrivileges`; long-lived
|
||||||
|
pool children are fine. Use `context.Background`-equivalent detachment so children
|
||||||
|
outlive the dispatch that created them.
|
||||||
|
|
||||||
|
## 7. Risks / open questions
|
||||||
|
|
||||||
|
- **opencode single-server blast radius:** one crash drops all opencode sessions. Mitigated
|
||||||
|
by on-disk session persistence + lazy re-create. Could later shard one server per project
|
||||||
|
if it bites.
|
||||||
|
- **Worktree disk growth:** persistent worktrees per session accumulate; the close-hook +
|
||||||
|
orphan reaper must be reliable or disk leaks. Add a max-live-worktrees cap with LRU evict.
|
||||||
|
- **SDK version coupling:** `@opencode-ai/sdk` is a new workspace dep pinned to the installed
|
||||||
|
opencode (1.15.x). Probe-time version check should warn on major drift.
|
||||||
|
- **Incremental-diff baseline correctness:** re-baselining after apply must handle the user
|
||||||
|
editing the project out-of-band; diff vs a stored base commit, not vs a moving target.
|
||||||
|
- **Reconnect fidelity:** after BooCoder restart, reattaching to a stored opencode session id
|
||||||
|
assumes the server (also restarted) still has it on disk — verify the SDK reattach path.
|
||||||
|
- **Cross-agent staging gap:** worktree agents and native boocode don't see each other's
|
||||||
|
*unapplied* edits (worktree vs project root). The UI must make this legible (e.g. show
|
||||||
|
which agent staged a pending change) so a switch doesn't look like lost work. A resumed
|
||||||
|
agent also won't have heard other agents' in-between turns — acceptable per the decided
|
||||||
|
model, but worth a small "N turns by other agents since you last ran" hint later.
|
||||||
|
- **Per-(chat,agent) session sprawl:** a chat that cycles through many agents accumulates
|
||||||
|
warm backends/worktree co-tenants; idle eviction (§6) must key on (chat,agent), and the
|
||||||
|
opencode server's session count is bounded by eviction, not per-chat.
|
||||||
|
|
||||||
|
## 8. File map (anticipated)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `apps/coder/src/services/agent-pool.ts` | NEW — pool + backend interface |
|
||||||
|
| `apps/coder/src/services/backends/opencode-server.ts` | NEW — SDK + SSE demux + dedup |
|
||||||
|
| `apps/coder/src/services/backends/warm-acp.ts` | NEW — persistent ACP connection |
|
||||||
|
| `apps/coder/src/services/dispatcher.ts` | per-chat concurrency; resolve-or-create shared worktree + per-(chat,agent) backend session; no per-turn teardown |
|
||||||
|
| `apps/coder/src/services/worktrees.ts` | chat-keyed create; baseline capture; re-baseline-on-apply |
|
||||||
|
| `apps/coder/src/services/agent-turn-persist.ts` | reused as-is |
|
||||||
|
| `apps/coder/src/schema.sql` | `session_worktrees` + `agent_sessions` (per (chat,agent)) + `pending_changes.agent` column |
|
||||||
|
| `apps/coder/src/routes/sessions|tasks` | chat-close cleanup hook |
|
||||||
|
| `apps/coder/src/routes/pending.ts` | `agent` on `listPending` response; stamp `agent` in queue paths |
|
||||||
|
| `apps/coder/src/routes/agent-sessions.ts` | NEW — `GET /api/sessions/:id/agent-sessions` (§9b) |
|
||||||
|
| `apps/coder/package.json` | add `@opencode-ai/sdk` dep |
|
||||||
|
| `apps/web/src/components/panes/CoderPane.tsx` | `PendingChange.agent`; DiffPanel badges + staging hint; pass `sessionId` to composer |
|
||||||
|
| `apps/web/src/components/AgentComposerBar.tsx` | optional `sessionId` prop; resumed/new chip; export `providerIcon` |
|
||||||
|
| `apps/web/src/hooks/useAgentSessions.ts` | NEW — chat-scoped agent-session fetch |
|
||||||
|
| `apps/web/src/api/client.ts` | `api.coder.agentSessions(sessionId)` |
|
||||||
|
|
||||||
|
## 9. Frontend UX — agent attribution & switch affordances
|
||||||
|
|
||||||
|
The switching model (§3a) is only good if it's **legible**: the user must see which
|
||||||
|
agent did what, and whether switching back resumes or starts fresh. Pure read+display
|
||||||
|
over the new `agent` column and `agent_sessions` — no dispatch-logic change.
|
||||||
|
|
||||||
|
### 9a. Per-change agent attribution (DiffPanel) — Phase 1
|
||||||
|
- **Wire:** `listPending` returns the row; add `agent` to the response and to the
|
||||||
|
frontend `PendingChange` type (`CoderPane.tsx`, today `{id, file_path, operation, diff?, status}`).
|
||||||
|
- **UI:** each DiffPanel row gains a small agent badge before the file path — reuse the
|
||||||
|
`providerIcon()` switch from `AgentComposerBar` (extract to a shared helper / the new
|
||||||
|
`icons/ProviderIcons` module) + the provider label; `agent === null` → a neutral
|
||||||
|
"manual" chip. When the pending set spans >1 distinct agent, a one-line header note
|
||||||
|
("Changes from opencode, boocode") makes mixed provenance obvious.
|
||||||
|
|
||||||
|
### 9b. "Resumed" vs "new session" indicator (AgentComposerBar) — Phase 1
|
||||||
|
- **API:** `GET /api/sessions/:id/agent-sessions` → `[{ agent, status, has_session, last_active_at }]`
|
||||||
|
(reads `agent_sessions` for the chat). Chat-scoped, so it is NOT foldable into the
|
||||||
|
project-level provider snapshot.
|
||||||
|
- **Hook:** `useAgentSessions(sessionId)` — fetch on mount, refetch on `message_complete`
|
||||||
|
(same trigger `usePendingChanges` already uses).
|
||||||
|
- **UI:** a subtle chip right of the Provider picker:
|
||||||
|
- current provider has a live row → muted **"resumed"** (title: "Resuming <agent> · last active <relative>").
|
||||||
|
- native boocode (never has a row) → **"history"** (it reconstructs from the transcript).
|
||||||
|
- otherwise → **"new session"**.
|
||||||
|
- Render only when connected and the chat has ≥1 prior turn; hidden on a fresh chat.
|
||||||
|
- `AgentComposerBar` gains an optional `sessionId?: string` prop (CoderPane has it);
|
||||||
|
absent → render nothing, so BooChat and other callers are unaffected.
|
||||||
|
|
||||||
|
### 9c. Staging-boundary hint (DiffPanel) — Phase 3 polish
|
||||||
|
- When the selected provider is **native boocode** and pending changes were staged by a
|
||||||
|
**worktree agent** (or vice-versa), show a one-line muted caveat:
|
||||||
|
"opencode's edits live in its worktree — boocode won't see them until applied."
|
||||||
|
Derived purely from per-change `agent` + current `value.provider`; no new state.
|
||||||
|
Keeps the §3a staging caveat from biting silently.
|
||||||
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal file
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# v2.6 Persistent agent sessions (warm processes + OpenCode server)
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
**Depends on:** v2.2 Paseo providers (ACP dispatch), v2.3 provider lifecycle (registry/snapshot)
|
||||||
|
**Reference fork:** `/opt/forks/paseo`, `/opt/forks/opencode`
|
||||||
|
**Pairs with:** the v2.5.x MessageBubble "Thinking" render fix — reasoning already flows; this batch is about persistence, not capability.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
BooCode dispatches external agents (opencode, goose, qwen) **one-shot per task**:
|
||||||
|
per task the dispatcher cuts a fresh worktree (`createWorktree(projectPath, taskId)`),
|
||||||
|
spawns `opencode acp` / `goose acp` / `qwen --acp`, runs **one** turn, then tears
|
||||||
|
down the process *and* the worktree (`dispatcher.ts:runExternalAgent`). Consequences:
|
||||||
|
|
||||||
|
- **No session continuity.** A follow-up message in the same chat creates a new
|
||||||
|
task with a new worktree and a new agent process. The agent has no memory of
|
||||||
|
the prior turn beyond what BooCode replays as chat history, and it cannot see
|
||||||
|
the files it edited last turn (fresh worktree every time).
|
||||||
|
- **Cold start every turn.** Each turn pays the process spawn + ACP `initialize`
|
||||||
|
handshake (and, for some agents, model load) before any work happens.
|
||||||
|
- **Diverges from Paseo.** Paseo runs **OpenCode as a long-lived HTTP server**
|
||||||
|
(`opencode serve` + `@opencode-ai/sdk`, SSE `/event` stream) and keeps **goose /
|
||||||
|
qwen as warm stdio-ACP processes** (`SpawnedACPProcess`: one ACP connection,
|
||||||
|
`newSession()` once, many `prompt()`s). BooCode rebuilds the world per turn.
|
||||||
|
|
||||||
|
This batch makes a BooCode chat map to a **persistent agent backend + a persistent
|
||||||
|
worktree** that live for the whole conversation, so turns are warm and the agent
|
||||||
|
sees its own accumulating edits. Reasoning passthrough is **already solved** (ACP
|
||||||
|
`agent_thought_chunk` → `reasoning_delta` → the new MessageBubble Thinking block);
|
||||||
|
this batch does not touch it beyond porting OpenCode's reasoning-dedup.
|
||||||
|
|
||||||
|
## Decisions locked (from design review)
|
||||||
|
|
||||||
|
- **Worktree model:** *Persistent worktree per session.* A chat owns one worktree
|
||||||
|
for the whole conversation; each turn the agent sees prior edits; pending_changes
|
||||||
|
accumulate; worktree is cleaned on session close, not per turn.
|
||||||
|
- **Agent switching:** *Free switch, per-agent memory.* The picker stays per-turn
|
||||||
|
(not locked to a chat). The worktree is shared across agents; each agent keeps its
|
||||||
|
own backend session, resumed when you switch back to it. Native boocode reconstructs
|
||||||
|
from chat history (so it sees every agent's turns); a resumed agent does not auto-
|
||||||
|
ingest the gap turns. Data model: one shared worktree per chat + one backend session
|
||||||
|
per `(chat, agent)` pair. Caveat: unapplied edits don't cross the worktree↔project
|
||||||
|
boundary between external agents and native boocode (a v2.5 review-model consequence).
|
||||||
|
- **Transport per agent (matches Paseo exactly):**
|
||||||
|
- **OpenCode** → one shared `opencode serve` HTTP server, driven via
|
||||||
|
`@opencode-ai/sdk`; one opencode *session* per BooCode chat (multi-session,
|
||||||
|
directory-routed via `x-opencode-directory`).
|
||||||
|
- **Goose / Qwen** → warm **stdio** ACP process per live session. Their HTTP
|
||||||
|
"server" modes are just ACP-over-HTTP wrappers (goose: undocumented/internal;
|
||||||
|
qwen `serve`: an HTTP bridge around a single `qwen --acp` child) — no gain over
|
||||||
|
stdio, so we keep stdio ACP like Paseo does.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
1. **Agent process pool** (`apps/coder/src/services/agent-pool.ts`) — owns long-lived
|
||||||
|
backends, lazy spawn, idle eviction, crash restart, shutdown drain.
|
||||||
|
2. **OpenCode server backend** — spawn `opencode serve`, hold SDK client + single
|
||||||
|
SSE subscription demuxed by opencode `sessionID` → BooCode session; port +
|
||||||
|
`OPENCODE_SERVER_PASSWORD` managed at boot.
|
||||||
|
3. **Warm ACP backend** — persistent `SpawnedACPProcess`-style connection for
|
||||||
|
goose/qwen reused across turns (one `newSession()`, many prompts).
|
||||||
|
4. **Persistent worktree lifecycle** — worktree created on first turn of a session,
|
||||||
|
reused, diffed incrementally into `pending_changes`, cleaned on session close.
|
||||||
|
5. **Session ↔ backend ↔ worktree mapping** — new `agent_sessions` table.
|
||||||
|
6. **Per-session concurrency** — replace the dispatcher's global single-flight
|
||||||
|
`running` guard with per-session serialization (different sessions run
|
||||||
|
concurrently; one turn at a time within a session).
|
||||||
|
7. **OpenCode reasoning dedup** — port Paseo's `streamedPartKeys` partID dedup so
|
||||||
|
reasoning isn't double-emitted (delta + final part).
|
||||||
|
8. **Switch-aware UI** (design §9) — per-change agent attribution in the DiffPanel
|
||||||
|
(`pending_changes.agent` column + badges), a resumed/new-session chip on the
|
||||||
|
AgentComposerBar (chat-scoped `agent-sessions` endpoint), and a staging-boundary
|
||||||
|
hint so the worktree↔project gap is legible.
|
||||||
|
9. **Tests + smoke** — pool lifecycle unit tests; multi-turn opencode smoke; switch
|
||||||
|
round-trip smoke; attribution/indicator smoke.
|
||||||
|
|
||||||
|
### Out of scope (this batch)
|
||||||
|
|
||||||
|
- Claude PTY→structured transport (separate deferred work — claude stays PTY here).
|
||||||
|
- Goose/qwen HTTP server modes (intentionally not used).
|
||||||
|
- Frontend redesign — existing CoderPane multi-turn chat UI already supports
|
||||||
|
follow-ups; only backend continuity changes.
|
||||||
|
- Replacing `acp-dispatch.ts` wholesale — warm backend reuses its event handlers.
|
||||||
|
- Cross-host agent servers (opencode server stays local to the BooCoder host).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Multi-user session sharing (single-user homelab).
|
||||||
|
- Multiple concurrent turns within one agent session (the agent holds conversational
|
||||||
|
state; turns within a session are serialized).
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Send two messages in one external-agent chat → second turn reuses the same agent
|
||||||
|
session **and** the same worktree (verified: no second `createWorktree`, agent
|
||||||
|
references files it edited in turn 1).
|
||||||
|
- Warm-start latency for turn 2 materially below turn 1 (no spawn/handshake).
|
||||||
|
- opencode reasoning shows once per thought (no dupes) in the Thinking block.
|
||||||
|
- Killing the opencode server mid-session → pool restarts it and the next turn
|
||||||
|
recovers (opencode persists sessions on disk).
|
||||||
|
- Switch opencode → boocode → opencode in one chat → opencode resumes its *same*
|
||||||
|
session (its memory intact), boocode saw opencode's turns as history, and all three
|
||||||
|
shared the one worktree. No agent is locked to the chat.
|
||||||
|
- Closing/archiving a session removes its worktree; BooCoder restart drains cleanly.
|
||||||
|
- Existing one-shot paths (arena, `new_task` tool, MCP create-task) still work.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
| Doc | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| [`design.md`](./design.md) | Architecture, backends, data model, worktree/diff strategy, lifecycle, risks |
|
||||||
|
| [`tasks.md`](./tasks.md) | Phased implementation checklist |
|
||||||
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal file
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# v2.6 Tasks — Persistent agent sessions
|
||||||
|
|
||||||
|
Phased so each phase is independently shippable and smoke-testable. Phase 1
|
||||||
|
(OpenCode server) delivers the most value on the cleanest API; goose/qwen warm
|
||||||
|
ACP follows; hardening last.
|
||||||
|
|
||||||
|
## Phase 0 — Foundations (no behavior change)
|
||||||
|
|
||||||
|
- [ ] 0.1 Add `session_worktrees` + `agent_sessions` tables (per `(session_id, agent)`)
|
||||||
|
to `apps/coder/src/schema.sql` (idempotent; see design §3).
|
||||||
|
- [ ] 0.2 Define `AgentBackend` / `AgentSessionHandle` interface + normalized `onEvent`
|
||||||
|
event union (reuse shapes from `acp-dispatch.ts`).
|
||||||
|
- [ ] 0.3 Scaffold `agent-pool.ts` with lazy get-or-create keyed by `(chat, agent)`,
|
||||||
|
health, `dispose()`; wire `app.addHook('onClose')` to dispose alongside dispatcher `stop()`.
|
||||||
|
|
||||||
|
## Phase 1 — OpenCode server backend (multi-turn, warm)
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `@opencode-ai/sdk` to `apps/coder/package.json`; pin to installed opencode major.
|
||||||
|
- [ ] 1.2 `backends/opencode-server.ts`: spawn `opencode serve` once (random
|
||||||
|
`OPENCODE_SERVER_PASSWORD`, allocated port), `createOpencodeClient`, wait for ready line.
|
||||||
|
- [ ] 1.3 Single `/event` SSE read loop; demux by `properties.sessionID`; map
|
||||||
|
`message.part.delta`/`updated` (text + reasoning) + tool parts to `onEvent`.
|
||||||
|
- [ ] 1.4 Port Paseo `streamedPartKeys` reasoning dedup (delta vs final part).
|
||||||
|
- [ ] 1.5 `ensureSession`: reuse the `(chat, opencode)` `agent_sessions` row if present
|
||||||
|
(resume on switch-back), else `client.session.create()` → store `agent_session_id`.
|
||||||
|
- [ ] 1.6 `prompt`: send via SDK with `x-opencode-directory` = session worktree + `model`.
|
||||||
|
- [ ] 1.7 Dispatcher: when `agent==='opencode'`, route to pool backend instead of
|
||||||
|
`dispatchViaAcp`; keep broker frames + `persistExternalAgentTurn` identical.
|
||||||
|
- [ ] 1.8 Persistent worktree: chat-keyed `createWorktree` (shared across agents);
|
||||||
|
capture base commit in `session_worktrees`; reuse across turns and agents.
|
||||||
|
- [ ] 1.9 Per-session concurrency: replace global `running` with `Map<sessionId,Promise>`;
|
||||||
|
`poll()` skips sessions with an in-flight turn.
|
||||||
|
- [ ] 1.10 Per-turn diff → supersede prior `pending_changes` row for the session (latest-wins).
|
||||||
|
- [ ] **Smoke 1:** two messages in one opencode chat → same `agent_session_id`, same worktree,
|
||||||
|
no second `createWorktree`; agent references turn-1 edits; reasoning shows once; turn-2 faster.
|
||||||
|
|
||||||
|
## Phase 1 (UX) — Attribution & switch affordances (design §9)
|
||||||
|
|
||||||
|
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent;
|
||||||
|
native write tools → `'boocode'`; manual RightRail create → NULL).
|
||||||
|
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type.
|
||||||
|
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge
|
||||||
|
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a).
|
||||||
|
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` +
|
||||||
|
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
|
||||||
|
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
|
||||||
|
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
|
||||||
|
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
|
||||||
|
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose.
|
||||||
|
|
||||||
|
## Phase 2 — Warm ACP backend (goose, qwen)
|
||||||
|
|
||||||
|
- [ ] 2.1 `backends/warm-acp.ts`: persistent spawn + `ClientSideConnection`; `initialize` +
|
||||||
|
`session/new` once; reuse `acp-dispatch.ts` `handleSessionUpdate`.
|
||||||
|
- [ ] 2.2 `prompt`: `session/prompt` on the warm connection per turn; per-turn abort signal only.
|
||||||
|
- [ ] 2.3 Child supervision: detached lifetime, exit handler marks `status='crashed'`.
|
||||||
|
- [ ] 2.4 Dispatcher routes `goose`/`qwen` to warm backend; keep one-shot fallback for arena/MCP
|
||||||
|
(or opt those into pool too — decide in review).
|
||||||
|
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
|
||||||
|
reasoning still renders; no per-turn respawn.
|
||||||
|
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
|
||||||
|
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
|
||||||
|
history, all three shared the one worktree, and no agent was locked to the chat.
|
||||||
|
|
||||||
|
## Phase 3 — Lifecycle hardening
|
||||||
|
|
||||||
|
- [ ] 3.1 Idle TTL eviction keyed per `(chat, agent)`; reattach-on-next-turn from `agent_sessions`.
|
||||||
|
- [ ] 3.2 Crash recovery: opencode server restart recreates sessions; ACP re-`session/new`.
|
||||||
|
- [ ] 3.3 Chat close/archive hook → `closeSession` for every `(chat, agent)` + remove the
|
||||||
|
shared `session_worktrees` row + worktree; mark agent rows `status='closed'`.
|
||||||
|
- [ ] 3.4 Orphan worktree reaper (extend periodic sweeper) + max-live-worktrees LRU cap.
|
||||||
|
- [ ] 3.5 Re-baseline worktree diff after `apply_pending`.
|
||||||
|
- [ ] 3.6 Reconnect test: restart BooCoder mid-session → next turn reattaches/recreates cleanly.
|
||||||
|
- [ ] 3.7 Staging-boundary hint in DiffPanel (§9c): muted one-liner when the selected
|
||||||
|
provider can't see another agent's unapplied worktree edits (derived from per-change
|
||||||
|
`agent` + current provider; no new state).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern).
|
||||||
|
- [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream).
|
||||||
|
- [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [ ] D.1 Update `CLAUDE.md` (BooCoder dispatch section) + `BOOCODER.md` health/contract.
|
||||||
|
- [ ] D.2 Note opencode `@opencode-ai/sdk` dep + `OPENCODE_SERVER_PASSWORD` env in env docs.
|
||||||
|
- [ ] D.3 `CHANGELOG.md` entry on tag (`v2.6.0-persistent-agent-sessions`).
|
||||||
|
|
||||||
|
## Build / deploy gate
|
||||||
|
|
||||||
|
- [ ] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean.
|
||||||
|
- [ ] B.2 `pnpm -C apps/server test` (+ DB-opt-in) green.
|
||||||
|
- [ ] B.3 Deploy: `sudo systemctl restart boocoder`; `curl :9502/api/health` reports tool count.
|
||||||
Reference in New Issue
Block a user