The tab (a chat) is the context unit: two opencode tabs in one session are two independent agent contexts sharing one worktree. agent_sessions re-keys from (session_id, agent) to (chat_id, agent) — chat_id FK ON DELETE CASCADE (closing a tab ends its context); worktree_id and session_id become informational SET NULL columns. New worktrees table (one-per-session, survives session delete via session_id SET NULL) supersedes session_worktrees, which is defanged (CASCADE dropped) not yet removed. chat_id is threaded end-to-end: tasks.chat_id added, written by the coder message + skills routes from the frontend tab, read by runOpenCodeServerTask which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic) so ensureSession never gets a null key. Idempotent migration with a backfill-verify gate (0-row assertion after the test session was deleted). config_hash fingerprint logic preserved; one-worktree-per-session unchanged; runExternalAgent untouched. Column rename worktree_path -> path repointed at all five readers (server delete-guard, risk/stash endpoints, ensureSessionWorktree). Supersedes the earlier (worktree_id) draft.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Schema, interface, and service scaffold for v2.6 persistent agent sessions.
Nothing in this batch alters runtime behavior.
- schema.sql: add session_worktrees (one shared worktree per session, FK
sessions(id)) and agent_sessions (one backend session per (session, agent),
with backend/status CHECKs); add pending_changes.agent column for DiffPanel
attribution. All three statements idempotent (IF NOT EXISTS).
- services/agent-backend.ts: AgentBackend interface + AgentSessionHandle,
EnsureSessionOpts, PromptCtx, TurnResult, and the normalized transport-agnostic
AgentEvent union (text/reasoning/tool_call/tool_update/commands). Types only.
- services/agent-pool.ts: lazy get-or-create AgentPool keyed by
`${sessionId}:${agent}` + shared `agentPool` singleton. Empty in Phase 0.
- index.ts: widen onClose to await dispatcher.stop() then agentPool.dispose()
(pool empty, so dispose() is inert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>