CHANGELOG entry for v2.6.7. Plus the session's doc reconciliation: roadmap shipped record synced through v2.6.7 (v2.3 lifecycle marked shipped, relicense AGPL->MIT batch, fork-sweep lift items, claude-agent-sdk SessionStore, ACP package fix); boocode_code_review_v2 (two fork sweeps, relicense decision = 3 AGPL files, jinja gate green); openspec v2-3 reconciled to shipped (v2.5.4-v2.5.13); openspec v2-6 Phase 0/1 + P1.5 shipped, F.1 done, remaining-phase plan + lift sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
v2.6 Design — Persistent agent sessions
Reference implementations: /opt/forks/opencode (server + SDK),
/opt/forks/paseo (warm ACP + opencode server-manager + reasoning dedup).
⚠️ Reconciled 2026-05-31 — read the proposal's Reconciliation note first. §2a and §3 describe the original design; four details were revised during implementation (per-session SSE;
(chat_id, agent)key +worktreestable;session.next.*events; password deferred) — flagged inline. Phases 2–3 and the Phase-1 UX (§2b, §6, §9) are not yet built; updated lift sources for them are in new §10.
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)
⚠️ Shipped deltas vs the bullets below: (a) per-session SSE — one
event.subscribe({directory})per live opencode session (P1.5-a,v2.6.2), NOT one global/eventloop; (b) events aresession.next.*(text.delta/reasoning.delta/tool.{called,success,failed}), NOTmessage.part.*; (c)OPENCODE_SERVER_PASSWORDdeferred — server binds loopback unsecured.
- 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
⚠️ Shipped (P1.5-b,
v2.6.3–v2.6.4):agent_sessionsis keyed(chat_id, agent)(the tab/chat is the agent-context unit;chat_idCASCADEs fromchats), and a first-classworktreestable (one-per-session, survives session delete viasession_idSET NULL) replacedsession_worktrees.tasks.chat_idthreads the tab id to the dispatcher. The SQL below is the original(session_id, agent)/session_worktreesshape — seeapps/coder/src/schema.sqlfor the live DDL.
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.
10. Lift sources for the remaining phases (added 2026-05-31)
From the second external review (boocode_code_review_v2.md). These supersede/augment §2b, §6, §9 for the unbuilt work:
- Phase 2 (warm ACP, goose/qwen) —
qwen --acpis a validated reference. qwen-code ships a real stdio multi-session ACP agent (Map<sessionID,Session>,loadSession/unstable_resumeSession, mid-session model/mode switch), sowarm-acp.ts(§2b) wires qwen into the existingacp-dispatch.tsstack as planned. Caveat: goose ACP exposes noloadSession/resume → its cross-restart resume needs a different design than opencode's (re-session/new+ accept memory loss, or replay). Cross-check qwen's@agentclientprotocol/sdk@^0.14vs BooCode's^0.22handshake before relying onunstable_resumeSession. (boocode_code_review_v2.md§5f, §5n.) - Phase 3 (lifecycle hardening) — lift from
openchamber(MIT, same warm-opencode-server architecture), not Paseo. Health-monitor + crash auto-restart + busy-aware restart (skip-while-busy + stale-grace) + port reclaim (killProcessOnPort/waitForPortRelease) + stall-detecting SSE reader — a concrete state machine for §6's "supervise children / rebuild on next turn" sketch. Worktree reaper: Paseo's worktree-archive cascade (soft-delete +Promise.allSettledfan-out) + superset's destroy-saga (preflight dirty/unpushed inspect + ordered failure semantics). Bound the warm server's per-session Maps (LRU) — long-lived-daemon leak class. (boocode_code_review_v2.md§5c, §5b, §5j.) - Fix-next (Phase 1/2) — the post-interrupt stale-terminal bug (confirmed live).
opencode-server.ts:~307settles anysession.idleonto whateveractiveTurnholds the session slot, with no turn-identity guard → after abort + new prompt, a stalesession.idlefrom the cancelled turn settles the new turn early as success. Paseo fix1d38aac(suppress-terminal-until-next-user-message). Now one-click reachable sincev2.6.5shipped the Send→Stop composer. (boocode_code_review_v2.md§1 #6, §3.) - Phase 1 UX (§9) — opencode already streams token/ctx usage.
session.next.step.endedcarries{tokens, cost}on the wire (SDK already installed) → consume it to fill ctx/token usage for opencode sessions, closing the "no usage for external agents" gap; surfaces beside the §9b chip. (boocode_code_review_v2.md§1 #8, §3.)