Skip to content

How session restore works

Background for vt-t-session-restore. Read this when a restore behaves unexpectedly, or before changing the scripts.

Two stores, two keys

Store Path Keyed by Survives account switch
Conversation transcripts ~/.claude/projects/<encoded-cwd>/<id>.jsonl folder yes
Desktop-app session list claude-code-sessions/<account>/<org>/local_*.json account and org — one sidebar each no, and not an org switch either

The encoded folder name is the absolute path with every character that is not a letter, digit or hyphen replaced by - — dots included. Rung 2 of the account ladder rebuilds that encoding, so the exact rule is load-bearing, not an aside. Everything the restore needs lives in the first store, which is why a fresh login with a different account finds an empty sidebar and a complete set of conversations at the same time.

Measured 2026-09-15: the first level is the account uuid — the folder name equals the ownerAccountUuid recorded in that account's bridge-session transcript entries — and the second is the organisation. One account can hold several org folders; this machine had three.

Each local_*.json carries, among others: cliSessionId (the transcript it points at), cwd, originCwd, worktreePath, branch, title, isArchived, lastActivityAt. claude-boot desktop reads those nine — sessionId as well — and treats every one as optional — the app's schema is not ours to depend on, so a missing field leaves an empty value rather than dropping the entry.

A CLI session started outside the app writes a transcript but no local_*.json. It therefore never appears in the sidebar through that store — it becomes visible via Remote Control instead.

Which account is the current one

claude-boot desktop has to decide that, and it prints the rule it used (current account: <uuid> (source: …)). Three rungs, in order:

  1. the literal prefix ~/Library/Application Support/Claude/scratch-workspaces/ in the running session's own working directory — the account uuid is the first path segment after it, the org the second;
  2. the same prefix inside the encoded directory names of the folder store, newest transcript first;
  3. the account folder holding the newest lastActivityAt.

ownerAccountUuid from bridge-session is NOT one of them, and must not be added back. It was rung 2 in the plan until it was measured: on 2026-09-15 the newest transcript of a current-account session carried the previous account's uuid, which inverted the entire report. The encoded directory name is correct here and costs no file read at all.

Carrying an entry over to the current account works (FR-15, measured 2026-09-16)

The sidebar of the current account can be given back a session from the previous account by copying its local_*.json file. Measured once, on one entry, with a backup and a rollback:

  1. the whole claude-code-sessions/ tree was backed up;
  2. the desktop app was quit completely (Cmd+Q);
  3. one file was copied unchanged, from <old-account>/<old-org>/ into <current-account>/<current-org>/ — the two org folders were different uuids, and that did not matter;
  4. the app was started again.

Outcome: the entry appeared in the sidebar, opened, showed its history, and accepted new messages. Those messages were appended to the original transcript (entrypoint: claude-desktop) — the conversation continued, it was not copied. Removing the file again (app closed) removed the entry; the messages written in between stay in the transcript.

The file was not edited. Nothing in it had to be rewritten for the other account or org.

The target org was the one in the current account's scratch-workspace path (<account>/<org>/), and the app was version 2.110.0 (/Applications/Claude.app/Contents/Info.plist, installed 2026-09-15).

A desktop session knows its own entry. The app passes CLAUDE_CODE_HOST_SESSION_ID=local_<uuid> to every child process, alongside CLAUDE_CODE_SESSION_ID (the transcript id) and CLAUDE_CODE_DESKTOP_APP_VERSION. The store folder holding local_<uuid>.json is that session's account and org — measured 2026-09-16 for the session that wrote this section. That is the only current-account signal that depends neither on the working directory nor on file times.

Quitting the app rejects every open question (measured 2026-09-16)

A session that is waiting on the owner — an AskUserQuestion without an answer — is not kept waiting when the desktop app is quit. The app writes a tool_result with is_error: true into the transcript: "The user doesn't want to proceed with this tool use … STOP what you are doing and wait for the user". After the restart the sidebar shows the question's header chip, not the question. The record's timestamp is the moment of the quit: three sessions that asked at 22:52, 00:36 and 04:15 all carry the same rejection second.

So the session now holds a rejection the owner never gave. Measured on two quits that day: three sessions lost their pending question, one of them twice — it re-asked after the restart and the second quit rejected that too. The question text survives in the transcript; only the answer path is gone.

Consequences for anyone about to quit the app:

  • answer every open question first, or note it;
  • after the restart, tell each affected session that the rejection was the app closing, not a decision — otherwise it proceeds as if the owner had declined.

Only AskUserQuestion was observed. Whether a pending permission prompt is recorded the same way was not measured.

"No session is working" and "no session is waiting" are different measurements. An hour without a transcript write proves the first and says nothing about the second.

pgrep cannot see the desktop app (measured 2026-09-16)

pgrep finds every Claude Helper process and never the app's main process /Applications/Claude.app/Contents/MacOS/Claude — not with -f, not with -x, not without a flag. ps -Ao command= lists it. A check "is the app closed?" built on pgrep answers yes while the app runs; one did, and a store experiment ran against the live store because of it (the store was verified unchanged against its backup afterwards). Check with ps.

pgrep does see the claude-code CLI processes (88 of 90 at the time).

While the app runs, its entry count is not a stable baseline: at start it created an entry for a due scheduled task within the first minute. Compare stores only with the app closed, or against a backup taken then.

The unit is a sidebar, not an account (BUG-309, measured 2026-09-25)

One login held two orgs, a team plan and a private plan. Switching org in the app emptied the sidebar exactly like an account switch: the store held 99 entries in the org just left, 86 of them active, and 60 of those had no counterpart in the org switched to. The report said 6 were missing — all six from other accounts — because it compared accounts, and every entry of the sibling org belonged to the current account.

The report now compares the current sidebar — (account, org) — against every other one. The org is known when a rule names one sidebar: this session's own entry (CLAUDE_CODE_HOST_SESSION_ID, unique per sidebar) or a scratch path, whose second segment is the org. It is printed as current org:. Without it the comparison falls back to account-wide, and an org switch inside that account stays invisible; that is the case to say out loud.

Duplicates and open questions stay account-wide (owner decision 2026-09-21): quitting the app touches every session of the login, and a conversation in two orgs of one account is reported as a duplicate on purpose.

What the report leaves out, and why

An entry of another sidebar is reported only when the current sidebar has nothing that covers it. Two rules drop an entry before the count is taken, and neither prints a line — so "0 Sessions fehlen" can also mean "every old entry matched a rule":

  • archived entries (isArchived) are never reported — a conversation is shown from its newest live copy, and that is the copy adopt takes (BUG-351);
  • an entry whose cliSessionId a live entry of the current sidebar also carries — that conversation is already back, possibly under a different sidebar title. An archived entry here does not count: the conversation is reported as missing, and adopt brings it back (BUG-351, below).

A third rule — an entry whose folder a current entry already claims — dropped the entry too until BUG-309. After a switch the owner wants every session back (decided 2026-09-25), so such an entry is now reported, marked Hinweis: … --allow-shared-folder. Two more marks exist: Auch in: when the same conversation sits in further sidebars (one block per conversation, the newest copy shown), and Hinweis: … --replace-older when this sidebar holds an entry of the same file name for another conversation — the same desktop session, continued separately in both orgs.

So a session you expect and do not see is most likely archived or already back, not lost. The same store read with --account/--org (PR 2) answers against a named account instead of the guessed one.

adopt: putting an entry back

claude-boot adopt --account <a> --org <o> <id>... copies the named entries' local_*.json from whichever sidebar holds them into <store>/<a>/<o>/. A sidebar is an account and an org: one login can belong to several orgs, and "already here" means this <a>/<o> pair, not the account (BUG-264). Nothing in the file is rewritten — the measurement above (FR-15) is the whole mechanism.

Which copy is the source. A conversation held by several other sidebars — what an earlier adopt leaves behind, since it copies and never moves — is taken from the copy with the newest lastActivityAt (BUG-309, owner decision 2026-09-25; before, it was refused outright, 45 of 60 on the measured store). An id prefix matching several conversations is always mehrdeutig.

A live copy beats an archived one, whatever their age (BUG-351, owner decision 2026-10-01: an archive in one sidebar suppresses nothing elsewhere). Archived copies are set aside before the copies are ranked, whenever a live one exists. The report counts live entries only and lists the session from its newest live copy; ranking the archived ones too took a strictly newer archived copy and refused it as archiviert, so the owner was refused what the report offered, and --all stopped there with nothing copied. The live copy is taken whatever else the copies differ in: BUG-346's measured case also differed in worktreePath, worktreeName, lastFocusedAt and remoteMcpServersConfig. The note names what was set aside, … — archiviert, nicht genommen: …, after Quelle: … when only one live copy was left. Only when every copy is archived does the newest of them stay the source, and it is refused as archiviert.

An archived entry here is not "already here" (BUG-351, same decision). The report counts this sidebar's live entries only, so a conversation archived here and live elsewhere is listed as missing, and adopt used to refuse it as schon im aktuellen Konto. Now it is brought back. Under another file name it is a plain copy beside the archived entry. Under the same file name (what an earlier adopt leaves when the owner archives it) the archived entry is replaced, without --replace-older and whichever copy is newer. The transcript is shared and not touched; only the sidebar entry changes. The run records it under replaced, the undo restores the archived bytes from the backup, and the Hinweis reads ersetzt den archivierten Eintrag hier (…). An archived entry of the same name for another conversation is still the --replace-older collision below: the archive alone never licenses an overwrite.

When the newest copies tie (equal or missing lastActivityAt; BUG-346, measured 2026-09-30: 7 of 62 ids of an org switch refused as mehrdeutig, none of them a real choice). One of the seven was a live copy tied with an archived one, which the rule above settles: the archived copy is set aside before there is a tie. BUG-346 first settled it at equal age only; BUG-351 removed that limit. The other six follow one rule:

  • Copies under one file name with the same bytes are one copy. Taking either writes the same file, so nothing is chosen (same sha1, same cliSessionId, one transcript). The note is byte-gleich, als eine Kopie gezählt: …. The file name is part of the rule because adopt copies the name too: the same bytes under two names still leave a choice of file, and stay mehrdeutig. Identical copies that are all archived are one archived copy and are refused as archiviert, not as a tie.

What is still tied after that stays mehrdeutig, and the note starts with the fields the copies differ in — unterscheiden sich in: title, … — then gleich alt in: and the copies. The fields come first because a printed note is clipped to 200 characters in the middle, and they are what the owner otherwise compares by hand. Dateiname marks a different file name; nur die Schreibweise der Datei means same name, every field equal, different bytes (key order or whitespace), which is not treated as one copy.

Opt-ins, off by default. --allow-shared-folder lets a second entry point at a folder this sidebar already has. --replace-older overwrites this sidebar's entry of the same file name when the source is strictly newer; the replaced entry's folder does not count against it. (An archived entry here for the same conversation needs no flag; see above.) --all takes every session the report lists as missing; inside the app the refusal prints the expanded ids with the opt-ins, so the run in Terminal.app copies what the owner saw.

The order of a run (adopt.py, run()), and why each step sits where it does:

  1. decide every id — read-only, so a refusal can name the accepted ones;
  2. app check; running, or unreadable, stops here with the refusal output;
  3. --account/--org must be given, and <store>/<a>/<o>/ must exist — a typo must not create an account folder;
  4. the backups root must lie outside the store and not be the store root itself, or the next backup would copy the last one and its files would sit in an account folder looking like entries. Equal and below are one rule: the first version tested only startswith(store + sep), which is false when the two are equal, so such a run passed the guard and copied the store into a directory inside itself;
  5. no accepted id → no backup at all: a backup without a copy is litter that looks like a run;
  6. shutil.copytree of the whole store into <backups>/<YYYYmmdd-HHMMSS>-<pid>/store, path printed;
  7. run.json written atomically: store_root, target_dir, created_at, files: [], replaced: [];
  8. the app check again — copying the store takes long enough for the owner to reopen the app;
  9. per id: append the target path to files and rewrite the record, then create the file with O_WRONLY|O_CREAT|O_EXCL. A crash between the two leaves a record naming a file that was never created, which the undo reports as already gone; the other order leaves a file no record knows about. On FileExistsError the path is taken back out of the record and the run continues. A replacing copy (--replace-older) is recorded under replaced first, written to a temporary sibling and renamed over the entry — the app never sees a half-written file, and a symlink at the final name is replaced, not followed;
  10. the undo command is printed, with the directory and both flags.

Backups are never deleted. One run copies the entire store — 17 MB on this machine, measured 2026-09-19 (du -sh of claude-code-sessions) — and keeping them is what makes an undo possible weeks later. CLAUDE_BOOT_ADOPT_BACKUPS moves the root; the default is ~/.claude/adopt-backups.

The target sidebar is the --account/--org given, else this session's own entry — never the ladder. The two flags come together or not at all, and each must be uuid-shaped — a folder name, not a path; anything else is a usage error before the app check. Whether the folder exists is checked later, after the app check. Without flags, CLAUDE_CODE_HOST_SESSION_ID names the entry inside the app, and its folder is the account and org. The FR-1a ladder is a guess for a report; writing a file into the wrong sidebar on a guess is a different order of mistake. An adopted session has the same file name in two sidebars — two accounts, or two orgs of one account — so its own entry is ambiguous by construction, and then no command is printed. The report's rung 0 asks only which account, so two orgs of one account still answer it.

No command is printed in one further case: when this session's account or org cannot be named safely. They are directory names, as attacker-authored as anything else in the store, and shlex.quote does not help — it is a shell guard and leaves newlines and ESC bytes intact inside the quotes it adds, where the terminal renders them. A flattened command would also be a command that differs from the one that works, so the tool prints none.

What is listed instead is every account/org pair, seeded from the directory layout rather than from the entries — so a pair holding zero entries still appears, which matters because run() accepts an empty org folder as a target and FR-17 records a measured account that had one. Each row carries the entry count and the newest lastActivityAt, because two pairs with the same count are not equally likely to be the owner's. An account whose folder cannot be read is named on its own line rather than silently dropped: a short list reads exactly like a complete one, and this list is the owner's only source for the flags.

What the undo trusts (undo(), D-18): the flags, the store as it is now, and the backup. Not the record — that is the one input an attacker or a later bug can edit, so it says which paths to consider and never whether they are safe. Three guards, in order: the path is not a symlink; its realpath sits exactly in <--account>/<--org>/ with a local_*.json name, resolved against the store the undo was pointed at rather than the one the record claims; and the backup does not already hold that file, because then it existed before the run. A replaced entry is put back instead of removed: the same first two guards, and the third inverted — the backup must hold the old bytes as a regular file, or there is nothing trustworthy to restore.

Missing pieces stop the whole undo rather than doing the checkable part: no readable run.json names <dir>/store/ and says that restoring it by hand also rolls back the app's own later changes; no <dir>/store/ is Sicherung unvollständig, because without it the third guard would answer "not in the backup" for every file on disk.

App detection is case-sensitive (Claude.app/Contents/): the Claude Code CLI ships as claude.app in lower case, and treating that as the app would make adopt refuse forever, even from a terminal with the app closed.

One process in the bundle is not the app (BUG-310, measured 2026-09-25): Contents/Helpers/chrome-native-host, the native-messaging host of the Claude browser extension. Chrome or Comet start it as their own child and keep it alive after the app has quit, so a bundle-wide match answered "running" for as long as a browser was open. It is excluded by that exact path; every other process in the bundle still counts, so an unknown new helper keeps the check fail-closed.

A limit, stated rather than hidden: the app can be reopened after the second check and before the copy. The window is milliseconds and the undo exists for exactly this, but it is not closed.

Drift: an empty store is a finding

If a store root exists but yields nothing — a renamed level, a changed layout — the report says so and exits 1. "Nothing is missing" and "I could not read the store" must never look the same (docs/solutions/patterns/critical-patterns.md P-005).

What the two state files hold

~/.claude/session-boot.json (written by scan) — one entry per repository:

{ "path": "/Users/…/F055-VisiFair Social Media",
  "slug": "f055-visifair-social-media",
  "session": "c2772526-…",
  "entrypoint": "cli",
  "last": "2026-09-12 08:24",
  "last_message_at": "2026-09-12T08:24:11+02:00" }

Six keys, not four: a manifest without last_message_at is schema v1 and is refused (see "Known limits"). The sample showed the v1 shape until 2026-09-17, i.e. an example this very tool rejects.

The slug is derived from the folder name and made unique across the manifest; on a collision the parent folder is prefixed. Uniqueness matters for the Remote Control identity and the printed claude.ai/code URL — not for the duplicate-start guard, which resolves by working directory (see "The occupancy guard"). It did compare slugs until 2026-09-15, and that is exactly what let open and resume run on one transcript.

~/.claude/session-inventory.json (written by inventory) — one entry per conversation, with first (opening user message), last_ai (closing assistant message), branch, turns and a newest flag.

Why the inventory does not use file timestamps

A dying Claude Code process still writes to its transcript. After a batch shutdown every file carries roughly the same modification time, which makes days-old conversations look current. The inventory therefore takes the timestamp of the last real user/assistant entry instead, and skips entries flagged isMeta (tool plumbing) or isSidechain (subagents).

--continue versus --resume

claude --continue picks the most recent conversation in the current directory. For a folder with one conversation that is what is wanted. For a folder with several — a repository worked on from multiple angles in one day — it is a coin toss, so sessions and resume <id> exist.

resume names each session <repo-slug>-<id-prefix>, so several conversations of one repository can run side by side without the duplicate-start guard blocking them.

Remote Control and naming

--remote-control <name> registers the session so it is reachable from other devices and prints a claude.ai/code/session_… URL. A resumed conversation reclaims the Remote Control identity it had before, so the sidebar may show its old title instead of the slug passed on the command line. That is expected, not a failure.

Registration happens after the folder-trust prompt. A session waiting at that prompt is invisible from every other device, which is why claude-boot-trust exists.

The occupancy guard

open asks, per folder, whether a session already runs in that folder: the working directory of every running claude process (via lsof -d cwd), plus its --resume id when the command line carries one. Three states, and the third one matters:

State Meaning open
free no process in that folder starts
occupied a process runs there; the reason names --continue or --resume <id> skips
occupied-unknown processes exist but their cwd is unresolvable (no lsof, unreadable ps) skips

Unknown counts as occupied on purpose: a needless skip costs a line of output, while a second process on one transcript costs the conversation.

Until 2026-09-15 the guard compared the --remote-control slug, which scan and resume build differently (40 vs 32 characters plus an id suffix). The two commands could therefore not see each other's processes, and on 2026-09-13 C040 ran --continue and --resume be62ba56 at the same time on transcript be62ba56. CLAUDE_BOOT_FORCE=1 still disables the guard.

If the occupancy lookup itself fails, open and list treat every folder as occupied-unknown. Until 2026-09-17 they fell back to an empty table — "everything free". Folder paths reach the lookup one per line, never as a word list: a path with a space (F055-VisiFair Social Media) used to split into words, match nothing, and count as free.

resume uses a narrower rule, because several conversations of one repository may run side by side. It skips when a process in the folder already resumes this conversation, when a --continue process is there (which conversation that one is on cannot be known), or when the process list or a working directory cannot be read. A process in the folder on a different --resume id does not block it. Until 2026-09-17 resume had no guard at all, so open followed by resume in one folder reproduced the C040 incident.

Starts are verified, not assumed

osascript … do script returns as soon as Terminal.app has accepted the text — long before a process exists, and sometimes none ever does. open therefore polls for a process in the target folder (bounded, CLAUDE_BOOT_START_TIMEOUT, default 5 s) and reports gestartet and FEHLSTART separately. On 2026-09-14 a worktree was reported as started and no process had been created.

resume verifies by the --resume id, not by folder: another conversation may already run in that folder, and a folder match would report that one as this start. A failed resume prints FEHLSTART and exits 1.

Known limits

  • Terminal automation (AppleScript) is required for open and resume; print is the fallback.
  • scan and inventory both cover the last 21 days. Override with CLAUDE_BOOT_DAYS.
  • CLAUDE_BOOT_EXCLUDE is a regex, by default dropping the desktop app's scratch workspaces. It filters scan only — desktop must see those sessions, because an entry without a project folder is exactly one of the cases it reports.
  • scan considers cli conversations only and names what it set aside; CLAUDE_BOOT_ALL_ENTRYPOINTS=1 on scan includes every surface. open has no filter of its own — it starts whatever the manifest holds, so setting the variable on open changes nothing.
  • Manifest schema v2 carries entrypoint and last_message_at. A manifest without the latter is from v1, where last held a file mtime; it is refused with a named reason rather than reread.
  • CLAUDE_BOOT_ADOPT_BACKUPS moves the backup root of adopt; it must not lie inside the store, nor be the store root itself — equal and below are one rule.
  • Redirectable for tests, and only for tests: CLAUDE_BOOT_PROJECTS, CLAUDE_BOOT_DESKTOP_STORE, CLAUDE_BOOT_MANIFEST, CLAUDE_BOOT_INVENTORY, CLAUDE_BOOT_SCRATCH_PREFIX, plus the capsule overrides CLAUDE_BOOT_PS, CLAUDE_BOOT_LSOF, CLAUDE_BOOT_GIT, CLAUDE_BOOT_OSASCRIPT. Behaviour switches, not test redirects: CLAUDE_BOOT_DAYS, CLAUDE_BOOT_EXCLUDE, CLAUDE_BOOT_ALL_ENTRYPOINTS, CLAUDE_BOOT_FORCE, CLAUDE_BOOT_REMOTE, CLAUDE_BOOT_PRINT, CLAUDE_BOOT_START_TIMEOUT.
  • A repository's own git config is not executed: git_state passes --no-optional-locks -c core.fsmonitor= -c core.hooksPath=/dev/null, because a planted core.fsmonitor is a shell command git status runs (measured 2026-09-17). list asks the same function, so the guarantee holds for every command that prints a git state.