diff --git a/.gitignore b/.gitignore index 4a99dba..bf40f12 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ data/* !data/AGENTS.md !data/skills/ !data/mcp.json +codecontext/fork.tar.gz diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6e8270f..0000000 --- a/AGENTS.md +++ /dev/null @@ -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//proposal.md` + `tasks.md` (see below) | - -## Verification (before claiming done) - -```bash -pnpm -C apps/server test && pnpm -C apps/server build -npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors -curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500 -curl http://100.114.205.53:9502/api/health # BooCoder on host -``` - -Deploy truth beats source-only reads — check running health + `git log --oneline -3`. - -## Hard rules (from CLAUDE.md) - -- **Do not commit or push** unless Sam explicitly asks. -- **No app-layer auth** — Authelia at the reverse proxy. -- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`. -- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only. -- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded. -- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors. -- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove. -- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated. -- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast. - -## Using openspec with Cursor - -Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding. - -### When starting a batch - -1. Create `openspec/changes//` (lowercase-hyphenated, e.g. `v2-2-arena-ui`). -2. Write `proposal.md` — why, scope, non-goals, dependencies. -3. Write `tasks.md` — numbered checkbox steps (build + smoke). -4. Optional `design.md` — schema/API decisions that outlive the batch. - -See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only. - -### Prompting an agent - -``` -@openspec/changes//proposal.md @openspec/changes//tasks.md -Implement tasks 1–3. Server tests must pass. Do not commit. -``` - -Attach the spec files with `@` so they load into context. Point at specific code paths when known: - -``` -@openspec/changes/v2-x/proposal.md -Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns. -``` - -### After shipping - -- Tag: `vMAJOR.MINOR.PATCH-slug` -- Add entry to top of `CHANGELOG.md` -- Move or snapshot the openspec folder to `archived/` if you want history preserved -- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked - -### What not to use openspec for - -- One-line bug fixes — just describe the bug + file. -- Exploratory questions — Ask mode + `@CLAUDE.md` is enough. -- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions. diff --git a/CHANGELOG.md b/CHANGELOG.md index dcad1ff..facd591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, ``, ``, `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. diff --git a/CLAUDE.md b/CLAUDE.md index d99c9af..0ff7adc 100644 --- a/CLAUDE.md +++ b/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 `. 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 - apps/web/dist/assets/index-*.js | sed -n '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/` 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/` 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` 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). diff --git a/codecontext/Dockerfile b/codecontext/Dockerfile index b61704b..529dd82 100644 --- a/codecontext/Dockerfile +++ b/codecontext/Dockerfile @@ -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 diff --git a/codecontext/shim.go b/codecontext/shim.go index 891c364..5e4b3c5 100644 --- a/codecontext/shim.go +++ b/codecontext/shim.go @@ -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 { diff --git a/data/AGENTS.md b/data/AGENTS.md index 3fd0602..80200d4 100644 --- a/data/AGENTS.md +++ b/data/AGENTS.md @@ -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) diff --git a/openspec/changes/v2-6-persistent-agent-sessions/design.md b/openspec/changes/v2-6-persistent-agent-sessions/design.md new file mode 100644 index 0000000..b0d642d --- /dev/null +++ b/openspec/changes/v2-6-persistent-agent-sessions/design.md @@ -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 // create-or-reuse + prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise + closeSession(handle): Promise + dispose(): Promise // 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

` + with `OPENCODE_SERVER_PASSWORD=` (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` 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 · last active "). + - 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. diff --git a/openspec/changes/v2-6-persistent-agent-sessions/proposal.md b/openspec/changes/v2-6-persistent-agent-sessions/proposal.md new file mode 100644 index 0000000..eaa2b22 --- /dev/null +++ b/openspec/changes/v2-6-persistent-agent-sessions/proposal.md @@ -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 | diff --git a/openspec/changes/v2-6-persistent-agent-sessions/tasks.md b/openspec/changes/v2-6-persistent-agent-sessions/tasks.md new file mode 100644 index 0000000..f545698 --- /dev/null +++ b/openspec/changes/v2-6-persistent-agent-sessions/tasks.md @@ -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`; + `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.