chore+docs: config, agent registry, codecontext, v2.6 spec, changelog
Working-tree config/doc changes (.gitignore, CLAUDE.md, AGENTS.md removal + data/AGENTS.md, codecontext Dockerfile/shim — pre-existing) plus this session's v2-6 persistent-agent-sessions openspec proposal/design/tasks (planning only; feature unimplemented, reserves the v2.6.0 tag) and the v2.5.2 CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.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.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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.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
|
||||
|
||||
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).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/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`.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- `/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.
|
||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
||||
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
|
||||
- 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 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.
|
||||
- `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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
@@ -15,9 +15,12 @@ WORKDIR /build
|
||||
|
||||
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.
|
||||
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
|
||||
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
|
||||
// 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).
|
||||
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
|
||||
childStdin, err = child.StdinPipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -258,3 +258,67 @@ Output:
|
||||
- Data flow map (entry → transform → output)
|
||||
- Conventions observed
|
||||
- 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)
|
||||
|
||||
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