# Plan — claude-cli SDK fork integration for "Trim from here" (v2)

## Goal
Make "Trim from here" actually work on cc-* (claude-cli) sessions. Currently the route
deletes DB rows but the claude-cli session keeps the original context, so the agent
"remembers" what was trimmed. Use the SDK's native `forkSession()` to also fork the
underlying claude-cli session at the same point and replace the session's
`claude_session_id` in place. Also add a real `POST /sessions/:id/fork` SDK fork.

## Background — what's currently in the codebase

- **messages table fields** (existing): `id, session_id, role, content, tool_calls,
  tool_call_id, model_key, input_tokens, output_tokens, cache_tokens, cost,
  thinking_content, thinking_tokens, attachments_metadata, created_at`. **No SDK
  UUID column.**
- **`db.deleteMessagesAfter(sessionId, messageId)`** ([infrastructure/database.js:432](infrastructure/database.js#L432)) — deletes `id > messageId`, updates `session.message_count` and `session.compact_size`. Used by `DELETE /sessions/:id/messages?after=…` AND `DELETE /sessions/:id/messages/after/:messageId`. **No claude session touched.**
- **`db.forkSession(sourceSessionId, upToMessageId)`** ([infrastructure/database.js:463](infrastructure/database.js#L463)) — DB-only fork: copies messages to a new session row, does NOT copy `claude_session_id`. Used by `POST /sessions/:id/fork`.
- **`db.resetSession(sessionId)`** ([infrastructure/database.js:286](infrastructure/database.js#L286)) — clears messages + counters but **does NOT clear `claude_session_id`** (criticizer #5).
- **`runClaudeChat`** ([core/router.js:579](core/router.js#L579)) reads `session.claude_session_id` and forwards to `runClaudeSession`.
- **`runClaudeSession`** ([engines/claude-engine.js:240](engines/claude-engine.js#L240)) destructures `forkSession`, `resumeSessionAt` from opts and wires to `sdkOptions` at line 339-342 — **dead code, nothing passes them anywhere in the codebase** (verified via grep below).
- **SDK exports `forkSession(sessionId, { upToMessageId?, title?, dir? }) → { sessionId }`** ([sdk.d.ts:569](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L569)). The `upToMessageId` is "inclusive" per [sdk.d.ts:575](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L575) JSDoc.
- **SDK assistant messages carry `sdkMsg.uuid`** (top-level on `SDKAssistantMessage`, see [sdk.d.ts:2171-2178](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L2171)). This is the UUID `forkSession()` expects.
- **Engine routing predicate** ([core/router.js:285,792,995,1073,1192](core/router.js#L285)) is `engine.type === 'claude-cli'` (from `resolveEngine({ settings, agent })`), NOT a model-prefix check. We will use the same.
- **`session.instance_folder` IS `cwd`** — verified: every `db.createSession` call in router.js passes `instanceFolder: cwd`. The SDK uses the same `cwd` to read/write session files. Safe to use `instance_folder` as `dir` for `sdk.forkSession()`.

## Tasks

### Task A — Persist the SDK message UUID per assistant row

Add `engine_metadata TEXT` column to the messages table (nullable JSON). Store
`{ "claude_uuid": "<sdkMsg.uuid>" }` for claude-cli `assistant` rows. Leave NULL
for openai-engine rows and historical rows (the SDK only exposes `upToMessageId`
for assistant messages).

**Files:**

1. `infrastructure/database.js`:
   - Add `ensureColumn(db, 'messages', 'engine_metadata', 'TEXT');` (idempotent).
   - Extend `addMessage()` signature with `engineMetadata` param. Persist as
     `engineMetadata ? JSON.stringify(engineMetadata) : null`. Add to the INSERT
     column list at [line 308](infrastructure/database.js#L308).
   - **Critical (criticizer #3):** extend `db.forkSession()` to ALSO copy the
     `engine_metadata` column for each duplicated row. Currently the INSERT at
     [lines 500-515](infrastructure/database.js#L500) doesn't list `engine_metadata`
     — must be added to both the column list AND the values list.
   - Add helper `getMessageEngineMetadata(sessionId, messageId)` returning parsed
     JSON or null.

2. `engines/claude-engine.js`:
   - In the `assistant` handler near [line 372-380](engines/claude-engine.js#L372),
     pass `engineMetadata: sdkMsg.uuid ? { claude_uuid: sdkMsg.uuid } : null` to
     `db.addMessage`.

### Task B — `db.findClaudeUuidUpToMessage(sessionId, messageId)`

Helper that finds the **last assistant row with id ≤ messageId** in a session
and returns its `claude_uuid` (parsed from `engine_metadata`). Returns null if
no qualifying row.

**Why "last assistant ≤ messageId" rather than "exact messageId":**
- The user might trim from a `user` row (their own message). The SDK's
  `upToMessageId` only accepts `SDKAssistantMessage.uuid`, so we anchor to the
  last assistant that came BEFORE or AT the trim point.
- If they trim from an assistant row, the exact assistant gets selected.
- If no prior assistant exists (trimming the very first user msg → effectively
  wiping the conversation): return null, caller treats as "fork to empty" =
  clear `claude_session_id` (next chat starts fresh). Justified because (a) the
  SDK has no concept of a session with only user messages and no assistant
  reply — by definition such a "session" hasn't started, and (b) the user's
  intent in this case IS to wipe.

### Task C — SDK fork helpers in claude-engine.js

Add and export:

```js
async function forkClaudeSession({ claudeSessionId, upToMessageId, title, cwd })
async function deleteClaudeSession({ claudeSessionId, cwd })  // for cleanup
```

`forkClaudeSession` calls `sdk.forkSession()` and returns the new SDK session
ID. Wraps SDK loader (existing `loadSdk()`); single try/catch; bubbles errors
with code `CLAUDE_FORK_FAILED`. `cwd` defaults to `process.cwd()` if not
provided (caller should always pass it explicitly).

`deleteClaudeSession` calls `sdk.deleteSession()` (also exported by the SDK,
[sdk.d.ts:449](node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts#L449)).
Used to clean up orphaned SDK sessions (criticizer #2). Failures here are
LOGGED but not thrown — cleanup is best-effort.

### Task D — Wire trim ("Trim from here") to SDK fork on cc-* sessions

The two trim routes (`DELETE /sessions/:id/messages?after=…` and
`DELETE /sessions/:id/messages/after/:messageId`) currently call only
`db.deleteMessagesAfter`. Add post-delete logic.

**Order of operations (revised after criticizer #1 race-condition concern):**

1. **Pre-flight: reject if session is actively running.** Use `runningSessions.has(id)`
   (already imported in router; trim route can require it). If true, return **409
   CONFLICT** with code `SESSION_BUSY`. Do NOT try to abort. Reasoning: aborting
   a live SDK generator mid-turn is unreliable (criticizer #1 — no guaranteed
   timeout); the user can simply wait for the current chat to finish and retry.
   This is a much simpler, predictable contract.

2. **Resolve engine type** the canonical way:
   ```js
   const agent = loadAgent({ cwd: session.instance_folder, name: session.agent_name });
   const engine = resolveEngine({ settings, agent });
   const isClaudeCli = engine.type === 'claude-cli';
   ```
   This addresses criticizer #6.

3. **For non-claude-cli sessions OR sessions without `claude_session_id`:**
   keep current behavior — call `db.deleteMessagesAfter`, return existing
   response. Add `claudeForked: false` field for explicitness.

4. **For claude-cli sessions WITH a `claude_session_id`:**
   - `claudeUuidAtAnchor = db.findClaudeUuidUpToMessage(id, messageId)`.
   - **Try SDK fork FIRST** (before DB delete) so we don't end up with deleted DB
     rows but a failed fork:
     - If `claudeUuidAtAnchor` is null: skip SDK fork; we'll just clear
       `claude_session_id` after the DB delete.
     - Else: call `forkClaudeSession({ claudeSessionId: session.claude_session_id,
       upToMessageId: claudeUuidAtAnchor, cwd: session.instance_folder })` →
       `newSdkId`. If this throws, return 500 `CLAUDE_FORK_FAILED` and DO NOT
       delete any DB rows. Session remains untouched.
   - **DB delete:** `db.deleteMessagesAfter(id, messageId)`.
   - **Update session:** `db.updateSession(id, { claudeSessionId: newSdkId || null })`.
   - **Cleanup (best-effort):** `deleteClaudeSession({ claudeSessionId:
     <oldClaudeSessionId>, cwd: session.instance_folder })`. Errors logged, not
     thrown.
   - Return existing JSON response shape + `claudeForked: true|false`.

This order eliminates the failure-recovery problem from criticizer #2 (the DB is
modified only AFTER the SDK fork succeeded).

### Task E — Wire `POST /sessions/:id/fork` to also do an SDK fork

`POST /sessions/:id/fork` creates a NEW VeilCLI session. For cc-* sessions,
also call SDK forkSession and store the new SDK session ID in the new session.

**Files:**

1. `infrastructure/database.js`:
   - Extend `db.forkSession()` to accept an optional `claudeSessionIdOverride`
     parameter. When provided, include it in the session-row INSERT at
     [line 482-498](infrastructure/database.js#L482) (`claude_session_id` is
     currently NOT in the column list — must be added).

2. `api/routes/sessions.js` `/:id/fork`:
   - Resolve engine same as Task D step 2.
   - Look up `claudeUuidAtAnchor = db.findClaudeUuidUpToMessage(...)`.
   - If `isClaudeCli && source.claude_session_id && claudeUuidAtAnchor`:
     `forkClaudeSession()` → `newSdkId`. Pass to `db.forkSession(sourceId,
     upToMessageId, { claudeSessionIdOverride: newSdkId })`. Response carries
     `claudeForked: true`.
   - Else (openai session, or claude-cli session with no usable anchor):
     fall back to current DB-only copy. `claudeForked: false`.
   - SDK fork failure: return 500, do NOT call `db.forkSession`. (Different
     from trim's behavior because fork creates a new artifact; user can retry
     cleanly.)

### Task F — Reset path: clear claude_session_id (criticizer #5)

`db.resetSession()` and `POST /sessions/:id/reset` ([api/routes/sessions.js:495](api/routes/sessions.js#L495))
clear all messages but leave `claude_session_id` set, causing the SDK to
"remember" the wiped messages on the next chat. Fix:

- `db.resetSession()` adds `claude_session_id = NULL` to its UPDATE statement.
- API route additionally calls `deleteClaudeSession()` (best-effort) on the
  old SDK session ID to clean up orphan storage. Errors logged.

### Task G — Dead `forkSession`/`resumeSessionAt` params (criticizer #7)

Verified via codebase grep (`grep -rn "forkSession\b" --include='*.js'` excluding
node_modules and the engine file itself): only `runClaudeSession` references
them; nothing passes them. Safe to remove the destructure and the lines 339-342
that build sdkOptions.

**Risk control (criticizer #7 partly valid):** to minimize blast radius, leave
the destructure but add a comment that they are reserved for in-query fork
mode and currently unused. The new SDK-fork path goes through `sdk.forkSession()`
instead. Net change: ZERO behavioral difference; just a comment. Defer actual
removal to a separate cleanup pass.

### Out of scope (documented limitations)

- **Single-message delete** (`DELETE /sessions/:id/messages/:msgId`): deletes
  a single row mid-conversation. No SDK equivalent (the SDK can fork at a
  point but can't surgically remove one message and keep subsequent ones).
  Will create context drift on cc-* sessions. **Documented as a limitation**
  — the UI should grey-out single-message delete on claude-cli sessions, OR
  document that the agent will still recall the deleted message until the
  session is wiped/reset. Per criticizer #4: scope OUT, don't try to fix.

## Files to modify

| File | Why |
|---|---|
| `infrastructure/database.js` | Add `engine_metadata` column; extend `addMessage`; extend `forkSession` to copy `engine_metadata` AND accept `claudeSessionIdOverride`; new helper `findClaudeUuidUpToMessage`; update `resetSession` to clear `claude_session_id`. |
| `engines/claude-engine.js` | Pass `sdkMsg.uuid` into `addMessage` for assistant rows; add and export `forkClaudeSession`, `deleteClaudeSession`; add reservation comment on dead in-query fork params. |
| `api/routes/sessions.js` | Wire SDK fork into trim routes (`/messages?after`, `/messages/after/:id`), `POST /:id/fork`, and reset cleanup. Engine-type detection via canonical `resolveEngine`. |

No new files. No migration script — `ensureColumn` handles `engine_metadata` on
existing DBs. Historical assistant messages will have NULL `engine_metadata`,
so trim/fork on a session whose history predates the fix will fall through to
"clear claude_session_id" path (acceptable; pre-existing sessions weren't
forkable anyway).

## Reused functions
- `loadSdk()` from claude-engine.js — already loads the SDK.
- `db.addMessage` — extended with `engineMetadata`.
- `db.deleteMessagesAfter` — kept, called AFTER successful SDK fork.
- `db.updateSession` — used to overwrite `claude_session_id`.
- `runningSessions.has` — for the busy-precondition guard before trim/fork.
- `resolveEngine` — for canonical engine-type detection.
- `loadAgent` — for engine resolution.

## Verification — real-world tests

The user-specified scenario:

1. **Test SDK fork (POST /sessions/:id/fork) with cc-haiku:**
   - Create session with cc-haiku, `instance_folder = /tmp/<uniq>`.
   - Send: `"X = 5, show me X"` → expect text containing "5".
   - Send: `"add 1 to x and show final x content"` → expect "6".
   - Send: `"add 5 to x and show final x content"` → expect "11".
   - Sanity: ask `"what is X"` → must say 11.
   - Look up the message id of the assistant response that said "6".
   - Fork at that message id.
   - On the FORKED session, ask `"what is X"` → must say **6**, not 11.
   - Verify response carries `claudeForked: true`.

2. **Test "Trim from here" with cc-haiku:**
   - Same X=5 → X=6 → X=11 build-up on a fresh session.
   - Note the session's `claude_session_id` value (V1).
   - Trim after the X=6 assistant message.
   - Verify session's `claude_session_id` is now V2 ≠ V1 (different).
   - Ask `"what is X"` on the SAME session → must say **6**.

3. **Negative test — trim with no prior assistant:**
   - Create cc-haiku session. Push 1 message with `pushToExistingSession`-like
     mechanism (or just ensure no assistant has replied yet — start session,
     don't wait, trim).
   - Trim at the user message id.
   - Verify `claude_session_id` is now NULL.
   - Next chat starts a fresh SDK session.

4. **Engine isolation:**
   - Trim on an OPENAI session (e.g. `assistant` agent). Verify behavior is
     unchanged from before, no SDK call attempted, response carries
     `claudeForked: false`.

5. **Fork preserves engine_metadata:**
   - After SDK-backed fork (test 1), inspect new session's messages — each
     assistant row carries `engine_metadata` with `claude_uuid`. Verify by
     trimming the FORKED session and confirming the fork-of-a-fork works.

6. **Busy session 409:**
   - Start a long-running cc-haiku turn (e.g. ask it to write a long file).
   - While it's running, attempt to trim. Verify 409 SESSION_BUSY response.
   - Wait for turn to finish. Retry trim. Should succeed.

7. **Reset clears claude_session_id (Task F):**
   - Create cc-haiku session, build up X=5/6/11.
   - Note `claude_session_id` value.
   - POST `/sessions/:id/reset`.
   - Verify session row's `claude_session_id` is NULL.
   - Next chat: ask `"what is X"` → must say "I don't know" or similar (no
     prior context).

## Criticizer round 2 (post-implementation)
After implementation, run a second criticizer specifically on:
- The actual code changes (not the plan)
- Whether the engine_metadata column is correctly threaded through ALL message
  paths (addMessage, forkSession, message read APIs)
- Whether the abort-on-busy contract is correctly enforced (no race where the
  busy check passes but a runtime starts mid-trim)
- Whether the response shape change (adding `claudeForked`) is documented or
  if any caller depends on the strict response shape
