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>
18 KiB
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>withOPENCODE_SERVER_PASSWORD=<random-at-boot>(verified:serve.ts,network.ts; default port 4096, printsopencode 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 byproperties.sessionID→ BooCode session. Reasoning arrives asmessage.part.delta(field: "reasoning") andmessage.part.updated(part.type: "reasoning"); text as thetextfield; tool calls as tool parts. - One opencode session per BooCode chat.
client.session.create()once, store the returnedidinagent_sessions.agent_session_id. Per-turn:client.session.prompt({ path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }}). Worktree routing via thex-opencode-directoryheader (set to the session's persistent worktree) so the agent operates inside it. - Reasoning dedup (port from Paseo
opencode-agent.ts): trackstreamedPartKeysofreasoning:${partID}; when amessage.part.updatedreasoning part arrives whose key was already streamed via delta, drop it. Prevents the double-thought bug (covered by Paseo'sopencode-reasoning-dedupe2e test).
2b. WarmAcpBackend (goose, qwen — stdio)
- One persistent process + ACP connection per (chat, agent) (Paseo's
SpawnedACPProcess): spawngoose acp/qwen --acponce, NDJSON over stdio,initialize→session/newonce; store the ACP session id in theagent_sessionsrow. Each turn callssession/prompton the same connection; switching away and back resumes this same connection/session. Reuses the existingacp-dispatch.tshandleSessionUpdateswitch verbatim foragent_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
promptgets 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.
-- 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):
-- 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/modelper message; nothing locks a chat to one agent. v2.6 keeps that. - Worktree is shared. All agents in a chat resolve the same
session_worktreesrow, 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
messagestable, 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 asbase_commit. Persist thesession_worktreesrow; 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_changesrow 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_pendingpath); 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 thesession_worktreesrow + allagent_sessionsrows for the chat. Orphan reaper sweeps worktrees with no livesession_worktreesrow (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
runningflag with aMap<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_newfast 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/newif the native id is gone). - Crash recovery: supervise children; on exit mark
agent_sessions.status='crashed', publishchat_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 dispatcherstop(). - systemd: BooCoder already spawns agent children under
NoNewPrivileges; long-lived pool children are fine. Usecontext.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/sdkis 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` |
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:
listPendingreturns the row; addagentto the response and to the frontendPendingChangetype (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 fromAgentComposerBar(extract to a shared helper / the newicons/ProviderIconsmodule) + 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 }](readsagent_sessionsfor the chat). Chat-scoped, so it is NOT foldable into the project-level provider snapshot. - Hook:
useAgentSessions(sessionId)— fetch on mount, refetch onmessage_complete(same triggerusePendingChangesalready 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.
AgentComposerBargains an optionalsessionId?: stringprop (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+ currentvalue.provider; no new state. Keeps the §3a staging caveat from biting silently.