Field notes for Claude Code users
Claude Code persistent sessions, what works out of the box and what you have to wrap
The transcript-level persistence in Claude Code is real and it works. What people usually mean by “persistent sessions,” though, is the experience of opening the app back up and finding every chat exactly where you left it, with the full history still live, and a one-click branch if you want to try a different direction. That is a host problem, not a CLI problem. This page is the honest map of the line between the two.
Direct answer (verified 2026-05-14)
Claude Code already persists every session to ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl automatically. Resume with claude --continue (latest session in the current directory) or claude --resume <id> (specific session). The transcript layer is fine. What is missing is auto-restore on launch, a way to survive an upstream session-ID roll (rate limits, credit exhaust, bridge restart), a way to avoid the SDK’s auto-compact silently dropping older context in long runs, and one-click forking. Any of those are solvable in a host on top of ACP; none is solvable from the bare CLI today.
Authoritative source for the resume mechanics: code.claude.com/docs/en/agent-sdk/sessions.
What “persistent” actually means in the CLI today
When the CLI runs a turn, it appends to a JSONL file under your home directory. The path is deterministic from the working directory: take the absolute path, replace every non-alphanumeric character with a hyphen, that is the folder name. Each session inside that folder is one file, named after its session ID. The transcript contains the user prompt, every tool call the agent issued, every tool result, and every assistant response, in order. The file is written incrementally so a crash mid-turn does not lose the prior turns. Nothing about this stops when you quit the CLI or reboot your Mac.
That is the persistent-sessions story you read about in the Anthropic docs. It is accurate and it is enough for a lot of people. The interesting question is what happens when you want the next layer up.
Three things the JSONL layer does not give you
These are the three gaps that turn “Claude Code already persists sessions” into “Claude Code sessions keep disappearing on me.” All three are real, all three are well-defined, and all three live above the transcript layer.
1. There is no auto-restore on launch
The CLI is, by design, stateless across invocations. You open a terminal, you typeclaude, you get a fresh session unless you remember to pass a flag. If you had four sessions open across four projects, that is four terminals, four cd commands, and four --continue flags. The transcripts are still on disk, but the workspace is not restored, the per-window model selection is not restored, and you are the one rebuilding the map every morning.
2. Auto-compact silently trims older context
When a session approaches the model’s context window, the SDK fires a compact_boundary event with a trigger of auto or manual and a preTokens count, then emits a compaction_start status change and a stream of compaction_delta chunks while it rewrites the older half of the conversation into a shorter summary. The summary replaces the originals in the live model window. The JSONL still has the full text. The model does not. In a long session this is where the agent “forgets” the path you established ninety turns ago: not because the file lost it, but because the live window did.
3. Forking is a manual session-id dance
If you want to try two different directions from the same point in a conversation, the best the CLI gives you is two terminals running --resume on the same ID. From that point they diverge with no parent-child link in the transcript file, no way to compare what each branch produced, and no UI affordance. The ACP layer (the protocol Claude Code speaks to its host) has an unstable session/fork RPC that creates a real branched session at a specific message. The CLI does not surface it. A host can.
What it looks like when a host owns the experience
The patch for all three gaps is structural, not heroic. A host that sits above ACP keeps its own registry of open windows (so it can recreate them on launch), keeps an append-only chain of every upstream session ID a conversation has owned (so rate limits do not strand history), and exposes the unstable fork RPC as a button. The same loop, with a different envelope around it.
Same agent loop, different host
Open a terminal, cd into the project, remember to type claude --continue, hope the auto-compact has not summarized away the path you established yesterday. To fork, open a second terminal, resume the same ID, and accept that the branch is a copy on the floor with no link back.
- Manual --continue or --resume on every restart
- Auto-compact rewrites older context in long runs
- Upstream session-id roll on rate limit strands earlier messages
- Fork = two terminals on the same id, no parent link
The actual code, in one shipping wrapper
Fazm is one of the hosts I work on. It is a native macOS app that runs the real Claude Code agent loop through ACP. The MIT-licensed source is at github.com/m13v/fazm. The three places that handle the three gaps:
Window auto-restore lives in Desktop/Sources/FloatingControlBar/DetachedChatWindow.swift. Function restoreWindows at line 644 reads a registry of every detached window from UserDefaults, loads the chat history for each (retrying up to 10 times with a 0.5 second sleep before deferring to the next launch), rebuilds each window at its saved frame, workspace directory, and selected model, and wires up the same callbacks the original window had.
// Desktop/Sources/FloatingControlBar/DetachedChatWindow.swift, line 644
func restoreWindows(chatProvider: ChatProvider) {
guard let data = UserDefaults.standard.data(forKey: Self.registryKey),
let snapshots = try? JSONDecoder().decode([WindowSnapshot].self, from: data),
!snapshots.isEmpty else { return }
for snapshot in snapshots {
Task { @MainActor in
var savedMessages: [ChatMessage] = []
for attempt in 0..<10 {
savedMessages = await ChatMessageStore.loadMessages(
context: "__\(snapshot.sessionKey)__",
limit: 100
)
if !savedMessages.isEmpty { break }
try? await Task.sleep(nanoseconds: 500_000_000)
}
// ...rebuild window at savedFrame, workspace, model
}
}
}The session-ID chain that outlives upstream rollovers lives in Desktop/Sources/Providers/ChatProvider.swift. Constant sessionChainMaxSize = 16 at line 614, helper appendToSessionChain at line 626. Every time the bridge hands back a new upstream session ID (because the old one was rate-limited or expired), the chain appends. On every send, the host loads recent messages across the full chain and forwards them as the preamble. The model sees one continuous conversation, the bridge sees whatever fresh session ID is current.
One-click fork lives in acp-bridge/src/index.ts, function handleForkSession at line 3824. It dispatches the upstream session/fork RPC (the unstable method exposed by @agentclientprotocol/claude-agent-acp via unstable_forkSession), registers the new session under the requested key, and sends back a session_forked event the UI uses to open the new window. The source session stays alive on disk.
// acp-bridge/src/index.ts, line 3824
async function handleForkSession(msg: ForkSessionMessage): Promise<void> {
const sourceEntry = sessions.get(msg.fromSessionKey);
if (!sourceEntry) {
logErr(`forkSession: source session not active; cannot fork`);
send({ type: "error", message: `Cannot fork: no active session` });
return;
}
const result = await acpRequest("session/fork", {
sessionId: sourceEntry.sessionId,
cwd: msg.cwd ?? sourceEntry.cwd,
mcpServers: buildMcpServers(mode, cwd, msg.toSessionKey),
}) as { sessionId: string };
registerSession(msg.toSessionKey, { sessionId: result.sessionId, cwd, model });
send({
type: "session_forked",
fromSessionId: sourceEntry.sessionId,
toSessionId: result.sessionId,
fromSessionKey: msg.fromSessionKey,
toSessionKey: msg.toSessionKey,
});
}None of this is heroic. The window registry is a JSON blob in UserDefaults. The chain is a deduped string list capped at sixteen entries. The fork is one RPC call. The payoff is that the experience of opening the app feels like opening a workspace instead of opening a terminal.
Bare CLI vs. ACP host, on the persistent-sessions axes only
| Feature | Raw claude CLI | Fazm (ACP host) |
|---|---|---|
| Transcript persistence to disk | Yes, ~/.claude/projects JSONL | Yes, same JSONL plus its own DB |
| Auto-restore on launch (no flags) | No, you pass --continue or --resume | Yes, every window restored at saved frame, workspace, model |
| Survives upstream session-ID roll | Transcript yes, model context no | Chain of up to 16 session IDs, replayed as preamble |
| Auto-compact in long sessions | SDK auto-compacts at the context-window edge | Full history stays live for the window's lifetime |
| One-click fork from any point | Manual --resume on two terminals, no parent link | Fork button calls session/fork, original chat untouched |
| Model and workspace per window | Per-invocation flags | Persisted with the window snapshot |
Picking the right rung
If you are a CLI-first user and you already script around --continue, the bare flag is fine. You can keep a tmux session alive across reboots, your terminal multiplexer holds your cwd, and the JSONL is doing the right thing under you. Most of the time it works. The places where it breaks (long runs, rate limits, branches) are real but rare for any given user.
If you find yourself losing context once a week and you can tell me which file path the agent forgot, you are hitting auto-compact, and the answer is either to keep sessions shorter or to move to a host that keeps the full history live. If your conversations die during the rate-limited part of the afternoon, you are hitting an upstream session roll, and the answer is a chain (build your own or use a wrapper that has one). If you want to try two directions on the same problem, you want a fork button.
Pick the lightest layer that fixes the specific gap you have. You do not need a desktop wrapper to use Claude Code well. You do need one if you want the persistent-sessions experience without the per-restart ritual.
“Fazm wraps the real Claude Code agent loop via ACP in a native macOS app. Persistent windows that survive a restart, one-click chat forking, no auto-compacting of context. MIT-licensed, fully local.”
github.com/m13v/fazm
Tired of re-typing --continue every morning?
Twenty minutes on how Fazm wraps Claude Code so windows auto-restore, forking is one click, and the rate-limit afternoon does not eat your context.
Frequently asked questions
Does Claude Code persist sessions across restarts by default?
Yes. The CLI writes every conversation to ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl as it runs. The encoded-cwd is your absolute working directory with every non-alphanumeric character replaced by a hyphen, so /Users/me/proj becomes -Users-me-proj. The transcript contains your prompts, tool calls, tool results, and assistant responses. Quitting the CLI does not delete the file, and rebooting your machine does not delete the file. The file is the persistence.
How do I resume a Claude Code session after quitting?
Two flags. `claude --continue` picks up the most recent session in the current working directory with no prompt. `claude --resume` opens an interactive picker over every session the CLI can find for the cwd, and `claude --resume <session-id>` jumps straight to one. Both flags rebuild context from the JSONL on disk: the prompt, every tool call, every tool result, every response, in order. The agent picks up exactly where it left off in the transcript.
If sessions are already on disk, why does anyone say Claude Code does not have persistent sessions?
Because the persistence is at the transcript layer, not the experience layer. You have to remember to pass a flag every time you open the terminal. If you had three sessions open in three terminals, you have to remember three session IDs (or live with the interactive picker). The upstream session ID itself can roll forward on a rate limit or credit exhaust, so the file on disk and the live session can drift mid-conversation. Auto-compact in long runs silently truncates older context. None of these is broken; they are the difference between a JSONL file and a workspace.
Where does auto-compact actually drop context?
When the running session approaches the model's context window, the SDK fires a `compact_boundary` event with a `trigger` (auto or manual) and a `preTokens` count, then a `compaction_start` and a stream of `compaction_delta` chunks while it rolls the older turns into a shorter summary. The summary replaces the originals in the live window. The JSONL transcript on disk still has the originals, but the live model sees the compacted version. In a long debugging session this is where you lose the file path you established eighty turns ago. The fix at the host layer is to either prevent the SDK from auto-compacting at all and let the conversation end naturally when it hits the wall, or to keep the full history live across windows.
Can I fork a Claude Code session, keep the original, and branch off a new conversation?
Not from the CLI directly. You can `claude --resume <id>` in two terminals and they will share the JSONL up to that point, but they will then diverge with no parent-child link in the transcript file and no way to compare branches later. The ACP layer exposes an unstable `session/fork` method (`unstable_forkSession` in the @agentclientprotocol/claude-agent-acp package) that creates a real branched session ID at a specific point in the parent's history. The CLI does not surface a one-click fork; a host on top of ACP can.
What happens to my session when the upstream rate-limits me mid-turn?
The SDK detects the unusable session, creates a fresh upstream session ID, and accepts a preamble of prior context. From your terminal it looks like a hiccup. From the JSONL store it looks like one conversation written under one ID up to the rate limit and a new conversation written under a new ID afterwards. `claude --resume` on the new ID will not show the pre-rate-limit history. You have to load both IDs. Most users do not, because they do not know there are two.
Is there a way to get persistent sessions in Claude Code without writing my own host?
Three practical options. (1) Stay in the CLI and use `claude --continue` plus tmux or your terminal multiplexer of choice to keep terminals alive across reboots. The transcript layer is enough if you can tolerate the gaps above. (2) Run Claude Code through an ACP-aware editor like Zed, which keeps each chat in an editor pane and survives an editor restart but does not solve auto-compact or fork. (3) Run a desktop wrapper. Fazm (the project this site documents) is one example, MIT-licensed at github.com/m13v/fazm, which auto-restores every window on launch, exposes one-click fork as a button, and avoids auto-compact by keeping the full chat history live for the lifetime of the window.
Where in the source can I read how a wrapper actually does this?
Three files in the fazm repo, all MIT-licensed. Window auto-restore is in Desktop/Sources/FloatingControlBar/DetachedChatWindow.swift, function restoreWindows around line 644: it reads a registry of windows from UserDefaults on launch, retries loadMessages up to 10 times with a 0.5 second sleep before deferring to the next launch, and rebuilds each window at its saved frame, workspace, and model. The session-id chain that survives upstream rollovers is in Desktop/Sources/Providers/ChatProvider.swift, constant sessionChainMaxSize=16 at line 614 with appendToSessionChain right below. One-click fork is in acp-bridge/src/index.ts, handleForkSession at line 3824, which dispatches the unstable session/fork RPC.
Does any of this need a different model than what the CLI gives me?
No. The whole point of going through ACP is that you keep the real Claude Code agent loop and the real Claude model. Your subscription is the same, your tool surface is the same, your prompts are the same. The wrapper changes the host: what gets stored, what gets restored on launch, what gets sent as the preamble on every send, and what gets fired into a new branch when you fork. The model is unchanged.
Session state, parallel agents, and the layer above the CLI
Adjacent reading
Agent persistent session state, the rollover trap nobody warns you about
Why the upstream session ID rolling forward on a rate limit silently strands your earlier messages, and the chain pattern that keeps the conversation continuous across the roll.
Claude Code parallel agents and file ownership
How to run several Claude Code agents on the same repo without them stepping on each other, and what ownership rules actually hold up under load.
Multi-agent Claude Code orchestration tradeoffs
The honest tradeoffs of orchestrating multiple Claude Code instances from one host, and where the model loop fights the orchestrator.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.