Files
boocode/openspec/changes/archived/claude-sdk-sessionstore/proposal.md

5.0 KiB

Claude Agent SDK backend + clean-room PostgresSessionStore (#9)

Status: shipped v2.7.5-claude-sdk-sessionstore Source: boocode_code_review_v2.md §1 #9, §5h/§5i (happy + SDK .d.ts). Decision §6.2: lean SDK. SDK: @anthropic-ai/claude-agent-sdk@0.3.159 (installed, Commercial Terms — runtime dep OK, code reference-only; the store is clean-room from the real interface, not vendored).

Replace BooCoder's one-shot PTY claude dispatch with a warm, resumable Claude-SDK backend. Two parts: the clean-room session store (fully testable here) and the backend + wiring (live pump needs a host smoke against real claude).

Ground-truth SDK API (from the installed sdk.d.ts)

  • query({ prompt: string | AsyncIterable<SDKUserMessage>, options?: Options }): Query where Query extends AsyncGenerator<SDKMessage, void>.
  • Options: sessionStore?: SessionStore, resume?: string, model?, cwd?, pathToClaudeCodeExecutable?, canUseTool?, permissionMode?, env?, allowedTools?.
  • SessionStore = { append(key, entries): Promise<void>; load(key): Promise<SessionStoreEntry[]|null>; listSessions?(projectKey): Promise<{sessionId,mtime}[]>; delete?(key): Promise<void>; listSubkeys?({projectKey,sessionId}): Promise<string[]> }.
  • SessionKey = { projectKey: string; sessionId: string; subpath?: string } (undefined subpath = main transcript; empty string invalid — store maps undefined→'' internally).
  • SessionStoreEntry = { type: string; uuid?: string; timestamp?: string; [k]: unknown } (opaque JSONL).
  • Messages: SDKSystemMessage{subtype:'init'} carries session_id (+ model/tools); SDKResultMessage (success/error) ends a turn with result, usage, total_cost_usd; SDKPartialAssistantMessage / SDKAssistantMessage carry text/thinking/tool blocks.

Part 1 — Clean-room PostgresSessionStore (testable now)

  • Schema (apps/coder/src/schema.sql): a generic append-only entry table claude_session_entries(id BIGSERIAL PK, project_key TEXT, session_id TEXT, subpath TEXT DEFAULT '', entry JSONB, created_at TIMESTAMPTZ DEFAULT clock_timestamp()) + index (project_key, session_id, subpath, id). (The store is generic per the SDK's key; the chat↔session ownership lives in agent_sessions, not here.)
  • apps/coder/src/services/backends/claude-session-store.ts: PostgresSessionStore implementing the real SessionStore type over Sql. append = ordered multi-INSERT (id = order); load = SELECT ORDER BY id → array or null; listSessions = group main-transcript rows, mtime = max(created_at) ms; delete = scoped delete (subpath given → that subpath; omitted → whole session); listSubkeys = DISTINCT non-'' subpaths. Pure SQL, no SDK import needed beyond the SessionStore type.
  • Tests __tests__/claude-session-store.test.ts (DB-opt-in, mirror checkpoints.test.ts): append→load round-trip + order, null on unseen key, subpath isolation (main vs subagent), listSessions mtime, delete scoping, listSubkeys.

Part 2 — ClaudeSdkBackend + wiring (live pump needs host smoke)

  • agent_sessions.backend CHECK adds 'claude_sdk'.
  • apps/coder/src/services/backends/claude-sdk.ts: a ClaudeSdkBackend implementing AgentBackend (mirror warm-acp.ts/opencode-server.ts). ensureSession resolves the resume id from agent_sessions(chat_id,'claude').agent_session_id; prompt drives one persistent query() in streaming-input mode (a pushable AsyncIterable<SDKUserMessage> fed per turn) with { sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }, reads the AsyncGenerator<SDKMessage> until result, captures session_id from the init message and persists it to agent_sessions. A pure mapSdkMessage(msg): AgentEvent[] (unit-tested) maps partial/assistant/tool/thinking → the existing AgentEvent union; result.usage/total_cost_usd accumulate onto agent_sessions (like opencode U.6). isBusy/closeSession/crash mirror the ACP backend.
  • Routing: add claude to the warm path (warm-acp-routing.ts or a sibling shouldUseClaudeSdk), with the existing PTY runExternalAgent kept as the fallback (session-less creators + if the SDK backend fails to start). Provider registry: claude stays selectable; transport reflects the SDK path.
  • Frames + persistence identical to the warm-ACP path (persistExternalAgentTurn, broker frames).

Verify

  • Part 1: pnpm -C apps/coder test + DB-opt-in store tests against dev postgres; build clean.
  • Part 2: pnpm -C apps/coder build + npx tsc -p apps/coder/tsconfig.json --noEmit (typechecks against the REAL SDK types) + pure-mapper unit tests. Live pump + resume across turns: host smoke against real claude (auth required) — cannot run from the dev container.

Open flags

  • SDK peer-deps want zod@^4; workspace is zod@3.25.76 (installed with a warning) — watch at runtime.
  • pathToClaudeCodeExecutable from available_agents.install_path; the SDK spawns the same claude binary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).