# Shared Plan v5 — CLI Switch Session Refresh (Issue #126)

## v5 changes (audit round 3)
- HIGH-A2: `cliSwitchRefresh` internal DB ops wrapped in `db.transaction(() => {...})()`. Any throw inside (insert, strict bootstrap write, session row write, bucket clear) atomically rolls back all four. Eliminates partial-side-effect residual risk.
- HIGH-A3: In `applyRuntimeSettingsPatch`, the cli-changed branch now SKIPS `syncMainSessionToSettings(prevCli)` and delegates main-session clearing to `cliSwitchRefresh`. On rollback we only need to revert the settings file — the original session row (with its `session_id`) is preserved because nothing touched it on this branch. The cli-unchanged branch keeps the original `syncMainSessionToSettings` call.
- Doc cleanup: header v5, removed stale "this plan v2" wording in devlog folder description.

## v4 changes — kept
- `setPendingBootstrapPromptStrict()` strict variant added to `main-session.ts`. `cliSwitchRefresh` uses it so bootstrap write failures propagate (and, in v5, trigger transaction rollback).
- Out-of-scope cleaned (no codex-spark contradictions). Acceptance criteria updated.

## v3 changes — kept
- bucket clear uses `resolveSessionBucket(toCli, toModel)` (codex-spark correctly targeted).
- harvest from `sourceWorkDir = prevWorkingDir`; marker row written to `targetWorkDir = settings.workingDir`.

## Problem

When user switches CLI via `/cli <name>` (or PUT /api/settings with `{cli: ...}`),
cli-jaw resets `session.session_id` but does NOT clear `session_buckets[<targetCli>]`.
Next spawn looks up `session_buckets[<targetCli>].session_id` (in `spawn.ts:732`) and
resumes the OLD session for that CLI. Bootstrap handoff is also never written.

Expected: switching CLI behaves like `/compact` — fresh session in the new CLI with
a one-shot bootstrap handoff containing the prior conversation summary.

## Root Cause

- `src/cli/handlers.ts` `cliHandler` only does `ctx.updateSettings({ cli: nextCli })`.
- `src/core/runtime-settings.ts` `applyRuntimeSettingsPatch` calls
  `syncMainSessionToSettings(prevCli)` which clears `session.session_id` BUT not
  `session_buckets[<targetCli>]`.
- `src/agent/spawn.ts:730–746` checks `getSessionBucket.get(targetCli)` first and
  resumes if found, bypassing the cleared main session row.
- `setPendingBootstrapPrompt` is never called → no handoff.

## Files

### A. MODIFY — `src/core/compact.ts`

Add NEW exported function `cliSwitchRefresh` alongside `autoCompactRefresh`.

```ts
export async function cliSwitchRefresh(opts: {
    sourceWorkDir: string;   // harvest from previous workingDir
    targetWorkDir: string;   // tag marker row with new workingDir
    fromCli: string;         // for system_notice text only
    toCli: string;           // tags marker row + bucket clear key
    toModel: string;         // tags marker row + bucket disambiguation (codex-spark)
}): Promise<{ refreshed: boolean }> {
    const slots = harvestBootstrapSlots({ workingDir: opts.sourceWorkDir, instructions: '' });
    const hasAnyContent = Boolean(
        slots.recent_turns || slots.memory_hits || slots.grep_hits || slots.task_snapshot,
    );
    if (!hasAnyContent) return { refreshed: false };

    const bootstrap = renderBootstrapPrompt(slots);
    const trace = `${BOOTSTRAP_TRACE_PREFIX}\n${bootstrap}`;

    // Phase 2: atomic DB mutation (v5 — single transaction, all-or-nothing)
    const { db, insertMessageWithTrace, clearSessionBucket } = await import('./db.js');
    const { resolveSessionBucket } = await import('../agent/args.js');
    const {
        writeMainSessionRow,
        buildClearedSessionRow,
        setPendingBootstrapPromptStrict,
    } = await import('./main-session.js');

    const targetBucket = resolveSessionBucket(opts.toCli, opts.toModel);
    const clearedRow = buildClearedSessionRow();

    const tx = db.transaction(() => {
        insertMessageWithTrace.run(
            'assistant', COMPACT_MARKER_CONTENT,
            opts.toCli, opts.toModel, trace, null, opts.targetWorkDir,
        );
        setPendingBootstrapPromptStrict(bootstrap);    // throws → rollback
        writeMainSessionRow(clearedRow);                // inlined clearBossSessionOnly
        if (targetBucket) clearSessionBucket.run(targetBucket);
    });
    tx();  // any throw inside → all four DB ops rolled back atomically

    // Phase 3: in-memory side effect (after DB committed)
    const { bumpSessionOwnershipGeneration } = await import('../agent/session-persistence.js');
    bumpSessionOwnershipGeneration();

    // Phase 4: notice (best effort, non-critical)
    try {
        const { broadcast } = await import('./bus.js');
        broadcast('system_notice', {
            code: 'cli_switch_refresh',
            text: `CLI switched ${opts.fromCli} → ${opts.toCli} — session refreshed`,
        }, 'public');
    } catch (e) {
        console.warn('[jaw:cli-switch] notice broadcast failed:', (e as Error).message);
    }

    return { refreshed: true };
}
```

Imports needed (already present in file):
- `COMPACT_MARKER_CONTENT`, `BOOTSTRAP_TRACE_PREFIX`, `harvestBootstrapSlots`, `renderBootstrapPrompt` — all already exported above in same file.

Dynamic imports needed (v5):
- `./db.js` exports `db` (verified at db.ts:223), `insertMessageWithTrace` (verified at db.ts:137), `clearSessionBucket` (verified at db.ts:170).
- `../agent/args.js` exports `resolveSessionBucket` (verified at args.ts:11).
- `../agent/session-persistence.js` exports `bumpSessionOwnershipGeneration`.
- `./main-session.js` exports `writeMainSessionRow` (main-session.ts:80), `buildClearedSessionRow` (main-session.ts:64), and the new `setPendingBootstrapPromptStrict` (added in B.1 below).
- `./bus.js` exports `broadcast` (only imported in the non-critical notice block).

### B. MODIFY — `src/core/runtime-settings.ts`

```diff
 export async function applyRuntimeSettingsPatch(
     rawPatch: Record<string, any> = {},
     opts: ApplyRuntimeSettingsOptions = {},
 ): Promise<Record<string, any>> {
     const prevCli = settings.cli;
     const prevWorkingDir = settings.workingDir;
     const prevSnapshot = { ...settings };

     const merged = mergeSettingsPatch(settings, rawPatch);
     const migrated = migrateSettings(merged);
     replaceSettings(migrated);
     saveSettings(settings);

     if (rawPatch.perCli?.codex && 'contextWindow' in rawPatch.perCli.codex) {
         ...(unchanged)
     }

     opts.resetFallbackState?.();
-    syncMainSessionToSettings(prevCli);
+
+    // v5: split branch — cliSwitchRefresh owns main-session clearing on the cli-changed path.
+    const cliChanged = !!(prevCli && settings.cli && prevCli !== settings.cli);
+    if (cliChanged) {
+        const toCli = settings.cli;
+        const toModel = settings.activeOverrides?.[toCli]?.model
+            || settings.perCli?.[toCli]?.model
+            || 'default';
+        try {
+            const { cliSwitchRefresh } = await import('./compact.js');
+            await cliSwitchRefresh({
+                sourceWorkDir: prevWorkingDir || '',
+                targetWorkDir: settings.workingDir || '',
+                fromCli: prevCli,
+                toCli,
+                toModel,
+            });
+        } catch (e) {
+            console.error('[runtime-settings] cli switch refresh failed, rolling back:', (e as Error).message);
+            replaceSettings(prevSnapshot);
+            saveSettings(prevSnapshot);
+            // DB ops were atomic via tx — no DB rollback needed.
+            // Main session row was untouched on this branch — original session_id preserved.
+            throw e;
+        }
+    } else {
+        syncMainSessionToSettings(prevCli);
+    }
+
     if (settings.workingDir !== prevWorkingDir) {
         ...(unchanged)
     }

     try {
         await restartMessagingRuntime(prevSnapshot, settings, rawPatch);
     } ...(unchanged)
```

### B.1 MODIFY — `src/core/main-session.ts` (v4)

Add strict variant alongside existing `setPendingBootstrapPrompt`:

```ts
/** Strict variant: throws on DB failure. Use when caller must know about persistence loss. */
export function setPendingBootstrapPromptStrict(text: string | null): void {
    if (text && text.trim()) {
        upsertMemory.run(BOOTSTRAP_KEY, text, BOOTSTRAP_SOURCE);
    } else {
        deleteMemory.run(BOOTSTRAP_KEY);
    }
}
```

Existing `setPendingBootstrapPrompt` (the swallow-and-warn version) is unchanged
to preserve callers that intentionally don't want to fail on bootstrap write loss.

### C. NEW — `tests/unit/cli-switch-refresh.test.ts`

| ID | Verifies |
|---|---|
| CSR-001 | `cliSwitchRefresh` returns `{ refreshed: false }` when slots are all empty |
| CSR-002 | When content exists, inserts COMPACT_MARKER row tagged with **toCli/toModel** + sets pendingBootstrap |
| CSR-003 | Clears `session_buckets[toCli]` row |
| CSR-004 | Broadcasts `cli_switch_refresh` system_notice with text containing both `fromCli` and `toCli` |
| CSR-005 | `applyRuntimeSettingsPatch` invokes `cliSwitchRefresh` exactly once when `cli` changes |
| CSR-006 | `applyRuntimeSettingsPatch` does NOT invoke `cliSwitchRefresh` when `cli` is unchanged |
| CSR-007 | `toCli='codex', toModel='gpt-5.3-codex-spark'` → `session_buckets['codex-spark']` cleared, `session_buckets['codex']` untouched |
| CSR-008 | Simultaneous `cli + workingDir` change: bootstrap harvest reads from prev workingDir's messages (not new dir's empty history) |
| CSR-009 | Refresh failure (e.g. `clearSessionBucket` or `setPendingBootstrapPromptStrict` throws) → `applyRuntimeSettingsPatch` rejects AND settings rolled back to prevSnapshot |
| CSR-010 | `setPendingBootstrapPromptStrict` throws on DB error (verified by mocked `upsertMemory.run` throwing) |
| CSR-011 | DB transaction atomic: when `setPendingBootstrapPromptStrict` throws inside tx, NEITHER marker row NOR bucket clear are persisted (transaction rollback) |
| CSR-012 | CLI-changed branch in `applyRuntimeSettingsPatch` does NOT call `syncMainSessionToSettings` (cliSwitchRefresh owns that responsibility) |

### D. NEW — devlog folder

`devlog/_plan/260423_cli_switch_no_compact/`
- `README.md` — frontmatter `status: planning`, links to issue #126
- `investigation.md` — root cause + code paths (this section, expanded)
- `plan.md` — this plan (current version: v5)

## Out of scope

- Bulk cleanup of all per-CLI buckets on switch. We only clear the bucket
  matching `resolveSessionBucket(toCli, toModel)` (which correctly handles
  codex / codex-spark disambiguation). Other CLIs' historical buckets are
  preserved for users who switch back-and-forth across many CLIs and
  intentionally rely on per-CLI isolated history (currently no such use case
  documented, but not invalidating existing buckets is the safer default).
- Web-UI side notice rendering for `cli_switch_refresh` code. Existing
  `auto_compact_refresh` rendering should handle generic system_notice
  events; per-code styling can be added later if needed.

## Acceptance criteria

1. `/cli claude` → `/cli codex` → `/cli claude`: third spawn boots a fresh
   claude session with bootstrap handoff (no resumed session_id).
2. PUT `/api/settings` with `{cli: "codex"}` from current claude triggers same flow.
3. `/cli claude` → `/cli claude` (no-op): nothing happens (guard short-circuits).
4. Empty conversation + CLI switch: skip silently, no marker row, no bucket clear.
5. Refresh failure: PUT /api/settings returns 500 AND settings.json reverted to prev value.
6. `tsc --noEmit` passes; CSR-001..012 pass.
