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:
- 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; - the same prefix inside the encoded directory names of the folder store, newest transcript first;
- 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:
- the whole
claude-code-sessions/tree was backed up; - the desktop app was quit completely (Cmd+Q);
- 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; - 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 copyadopttakes (BUG-351); - an entry whose
cliSessionIda 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, andadoptbrings 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, samecliSessionId, one transcript). The note isbyte-gleich, als eine Kopie gezählt: …. The file name is part of the rule becauseadoptcopies the name too: the same bytes under two names still leave a choice of file, and staymehrdeutig. Identical copies that are all archived are one archived copy and are refused asarchiviert, 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:
- decide every id — read-only, so a refusal can name the accepted ones;
- app check; running, or unreadable, stops here with the refusal output;
--account/--orgmust be given, and<store>/<a>/<o>/must exist — a typo must not create an account folder;- 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; - no accepted id → no backup at all: a backup without a copy is litter that looks like a run;
shutil.copytreeof the whole store into<backups>/<YYYYmmdd-HHMMSS>-<pid>/store, path printed;run.jsonwritten atomically:store_root,target_dir,created_at,files: [],replaced: [];- the app check again — copying the store takes long enough for the owner to reopen the app;
- per id: append the target path to
filesand rewrite the record, then create the file withO_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. OnFileExistsErrorthe path is taken back out of the record and the run continues. A replacing copy (--replace-older) is recorded underreplacedfirst, 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; - 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
openandresume;printis the fallback. scanandinventoryboth cover the last 21 days. Override withCLAUDE_BOOT_DAYS.CLAUDE_BOOT_EXCLUDEis a regex, by default dropping the desktop app's scratch workspaces. It filtersscanonly —desktopmust see those sessions, because an entry without a project folder is exactly one of the cases it reports.scanconsiderscliconversations only and names what it set aside;CLAUDE_BOOT_ALL_ENTRYPOINTS=1onscanincludes every surface.openhas no filter of its own — it starts whatever the manifest holds, so setting the variable onopenchanges nothing.- Manifest schema v2 carries
entrypointandlast_message_at. A manifest without the latter is from v1, wherelastheld a file mtime; it is refused with a named reason rather than reread. CLAUDE_BOOT_ADOPT_BACKUPSmoves the backup root ofadopt; 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 overridesCLAUDE_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_statepasses--no-optional-locks -c core.fsmonitor= -c core.hooksPath=/dev/null, because a plantedcore.fsmonitoris a shell commandgit statusruns (measured 2026-09-17).listasks the same function, so the guarantee holds for every command that prints a git state.