Skip to content

Instantly share code, notes, and snippets.

@n3tman
Created July 30, 2026 01:17
Show Gist options
  • Select an option

  • Save n3tman/01d44696c3dc63ec95dcf3a4f6166b60 to your computer and use it in GitHub Desktop.

Select an option

Save n3tman/01d44696c3dc63ec95dcf3a4f6166b60 to your computer and use it in GitHub Desktop.
Recover Claude Code Desktop sidebar sessions from CLI history (Windows, MSIX)

Claude Desktop β€” Session Recovery

Rebuilds the Claude Desktop Code sidebar when your conversations have vanished from it.

Your conversations are not stored in the sidebar. Claude Code keeps each one as a .jsonl transcript under ~/.claude/projects/, and the sidebar is a separate index of small local_*.json registration files. Lose the index β€” most often by reinstalling or resetting the app β€” and every conversation is still sitting on disk while the sidebar comes up empty.

This script reads the transcripts and rebuilds the missing registrations, restoring each session's real title, model, working directory and timestamps so the sidebar looks the way it did before.

It only ever adds registration files. Transcripts are read-only to it, and sessions already listed in the sidebar are skipped, so it is safe to re-run.

Requirements: Windows, Windows PowerShell 5.1 or PowerShell 7. No admin rights, no dependencies.


Quick start

1. Preview β€” writes nothing. Safe to run with the app open:

powershell -ExecutionPolicy Bypass -File "C:\Tools\restore-claude-sessions-fixed.ps1" -DryRun

You'll get a list of projects and, per session, the date, model and title it resolved. If it can't find your installation, the error lists every path it probed.

2. Back up. The sidebar index (this is what the script rebuilds):

Copy-Item "$env:LOCALAPPDATA\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code-sessions" "$env:USERPROFILE\Desktop\ccs-backup" -Recurse

And the transcripts β€” the only part that cannot be recreated:

Copy-Item "$env:USERPROFILE\.claude\projects" "$env:USERPROFILE\Desktop\claude-projects-backup" -Recurse

On a non-MSIX install, the index lives at "$env:APPDATA\Claude\claude-code-sessions" instead.

3. Fully quit Claude Desktop, including the system-tray icon. The app holds this index in memory and can overwrite new files when it exits.

4. Run it:

powershell -ExecutionPolicy Bypass -File "C:\Tools\restore-claude-sessions-fixed.ps1"

Press A to restore every project, or enter comma-separated numbers for specific ones, then confirm.

5. Reopen Claude Desktop. The sessions are back in the sidebar, correctly titled and sorted by last activity.

Switches

Switch Effect
-DryRun Preview only, writes nothing
-Yes Non-interactive, skips all prompts
-Project <folder> Restrict to one project folder, e.g. C--Code-my_project

Where everything lives

What Where Survives a reinstall?
Conversations (.jsonl) %USERPROFILE%\.claude\projects\<project>\ Yes β€” outside the app package
Sidebar index (local_*.json) %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code-sessions\<account>\<org>\ No

Project folders are named after the working directory with separators flattened to dashes, so C:\Code\my_project becomes C--Code-my_project.

If you have an MSIX (packaged) install

Claude Desktop may be installed as an MSIX package β€” it shows up under Settings β†’ Apps as a packaged app. Windows then redirects the app's writes to %APPDATA%\Claude into the package container. Inside the app's own sandbox that path looks perfectly normal, but a PowerShell, Explorer or Total Commander window started outside the sandbox sees the real filesystem, where %APPDATA%\Claude does not exist at all.

That trips people up twice: file managers appear to be "hiding" the folder, and any script hardcoding %APPDATA%\Claude aborts saying it isn't installed. The real path is the %LOCALAPPDATA%\Packages\... one in the table above. This script probes both and picks whichever actually contains claude-code-sessions, so it works from either side.


Why the sidebar gets wiped

The usual trigger is Windows refusing to launch the app:

There's a problem with Claude. Reinstall the application from its original install location or contact your administrator.

That is Windows' standard message for a broken MSIX package registration β€” the package is still listed, but its staged files or manifest can no longer be validated. It says nothing about your data being damaged. Reinstalling fixes the launch, and that is what clears the sidebar: the index lives in the package's LocalCache, which is package state and goes with it. The transcripts sit in your user profile, outside the package, so they survive untouched.

If you hit that error, work from least destructive to most:

Action Sidebar index Transcripts
Settings β†’ Apps β†’ Claude β†’ Advanced options β†’ Repair preserved preserved
Re-register the package (below) preserved preserved
Advanced options β†’ Reset wiped preserved
Uninstall + reinstall wiped preserved

Re-registering often repairs registration damage without a reinstall and keeps your data. Run it in Windows PowerShell 5.1, not pwsh 7 β€” the Appx module won't load there (Operation is not supported on this platform):

$p = (Get-AppxPackage Claude).InstallLocation
Add-AppxPackage -Register "$p\AppxManifest.xml" -DisableDevelopmentMode

Whichever route you take, the transcripts are the irreplaceable part β€” back up ~\.claude\projects first.


Credit, and what's different here

This builds on XPOL555's original script, which worked out the approach that makes any of this possible: that the sidebar is just an index, and that it can be regenerated from the transcripts.

Testing it against ~320 real sessions surfaced several bugs that made it unusable as-is, so this is a rewritten version of the internals. Everything below was reproduced on real data.

1. StrictMode silently discarded all metadata

The original ran Set-StrictMode -Version Latest, under which reading a property that doesn't exist throws PropertyNotFoundException. Its parse loop checked $obj.timestamp, then $obj.model β€” and no transcript line has a top-level model (it lives at message.model). So nearly every line threw at that second check and landed in an empty catch {}, before cwd or the user's message were ever read.

Running its extraction function verbatim against a real transcript:

Field Original Actual value
Title Session 013cc6bb-7992-405c-ad12-cff998d3d08c WhatsApp catalog workstream approach
Model claude-sonnet-5 (the hardcoded default, always) claude-opus-4-8
Cwd F:\Projects-TG-Mod β€” does not exist F:\Projects\TG_Mod

So it reported success while registering every session under a nonexistent directory with a UUID for a name. Fixed with Set-StrictMode -Version 1.0 plus a safe property accessor throughout.

2. The real titles were never read

Transcripts already contain dedicated title records:

{"type":"custom-title","customTitle":"WhatsApp catalog workstream approach","sessionId":"..."}
{"type":"ai-title","aiTitle":"Explore WhatsApp catalog implementation approach","sessionId":"..."}

318 of 320 sessions had one stored. Titles now resolve by priority β€” last custom-title (renamed by you) β†’ last ai-title (auto-generated) β†’ first prompt text β†’ Session <short-id>. Both kinds are appended repeatedly during a session, so the last occurrence is the current one, and titleSource is set to match. The fallback also strips harness boilerplate (<system-reminder>, <local-command-caveat>, …) that would otherwise become the visible title.

3. Project paths were decoded lossily

The folder-name encoding flattens both \ and _ to -, so it cannot be reversed by string substitution: C--Code-my_project decodes to C:\Code-my_project, not C:\Code\my_project. The decoder is gone β€” cwd now comes from the transcript, which records it verbatim. Transcripts without a cwd are skipped with a warning instead of being filed under a guess.

4. Sidebar ordering was scrambled

The original read only the first 200 lines per file, so lastActivityAt recorded roughly when a session started. Since the sidebar sorts by recency, the order came out wrong: 134 of 316 sessions were mis-dated, the worst by 10 h 37 m. The whole file is now scanned for timestamps. To stay fast, scalars are pulled with IndexOf/Substring on the compact JSONL, and a full ConvertFrom-Json runs only for the few lines that matter.

5. It crashed on the PowerShell that ships with Windows

Measure-Object : The property "MissingCount" cannot be found in the input for any objects.
    + FullyQualifiedErrorId : GenericMeasurePropertyNotFound,...

Measure-Object -Property reads object properties, not hashtable keys. PowerShell 7 unwraps hashtable keys as properties; 5.1 does not. The original does this twice, so on stock Windows PowerShell it died right after listing the projects and only worked if launched from pwsh 7. Replaced with explicit summation.

6. MSIX installs aborted at startup

Hardcoding %APPDATA%\Claude fails outside the app sandbox, as described above. Both locations are now probed.

Also changed

  • Correct model β€” from message.model, ignoring "isSidechain":true lines so a sub-agent's model (e.g. Haiku) isn't mistaken for the session's.
  • Schema parity β€” emits sessionSettings and completedTurns like the app does. User turns are counted via promptId, so tool results (also stored as "type":"user") don't inflate the count.
  • Skips junk β€” sessions with no recorded cwd or zero user turns are reported and skipped rather than registered as empty entries.
  • Preview before writing β€” the run prints date, model and resolved title per session before anything is written.
  • Idempotent β€” existing registrations are matched by cliSessionId, so re-running only adds what's missing.
  • -DryRun, -Yes and -Project switches.
  • The original's UTF-8 BOM detection and repair pass is kept as-is.

Verified on

  • Windows 10 Pro 19045, MSIX install of Claude Desktop
  • Windows PowerShell 5.1.19041.6456 and PowerShell 7.6.4 β€” identical results
  • 320 transcripts across 5 projects
  • Non-ASCII titles (Cyrillic) round-trip exactly: output is BOM-free, literal UTF-8, and re-parses to the original string on both PowerShell versions

Only tested against an MSIX install. The non-packaged code path is straightforward but unverified β€” reports welcome.


Troubleshooting

Nothing to restore, but the sidebar is empty. The script matches transcripts to registrations by cliSessionId. If it reports everything as up to date, the registrations exist and the problem is elsewhere β€” try quitting the app fully and reopening.

A restored session opens empty. Its registration points at a transcript the app can't match. Check that cwd in that local_*.json matches the real project directory; delete that one file and re-run. Other sessions are unaffected.

Rollback. The script only adds local_*.json files and never touches transcripts. To undo, delete the claude-code-sessions folder and put your backup copy back in its place.

#Requires -Version 5.1
<#
.SYNOPSIS
Recover Claude Code Desktop sessions missing from the sidebar (fixed edition).
.DESCRIPTION
Claude Code keeps every conversation as a .jsonl transcript in
~/.claude/projects/. The Desktop app's sidebar is a separate index of
local_*.json registration files. If that index is lost - most commonly by
reinstalling or resetting the app - the conversations still exist on disk but
the sidebar comes up empty.
This script reads the transcripts and rebuilds the missing registrations,
restoring each session's real title, model, working directory and timestamps.
It only ever ADDS registration files. Transcripts are read-only to it, and
sessions already present in the sidebar are left alone, so it is safe to
re-run.
.NOTES
Based on the original by XPOL555:
https://gist.github.com/XPOL555/1003cb862a88561dfad3f843f74de68f
That version had the right idea but several bugs made it unusable as-is: a
StrictMode setting that silently discarded every title, cwd and model; a
lossy path decoder; a 200-line read cap that scrambled sidebar ordering; and
a Measure-Object call that crashes on Windows PowerShell 5.1. See README.md
for the full breakdown.
Works on Windows PowerShell 5.1 and PowerShell 7. MSIX (packaged) installs
are detected automatically, so it can be run from inside or outside the app
sandbox. Run as yourself; elevation is not required.
powershell -ExecutionPolicy Bypass -File restore-claude-sessions-fixed.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File restore-claude-sessions-fixed.ps1
Close Claude Desktop fully (including the tray icon) before running for real,
and reopen it afterwards.
#>
[CmdletBinding()]
param(
[switch]$Yes, # skip confirmation prompts
[switch]$DryRun, # show what would be created, write nothing
[string]$Project # only this project folder name (e.g. F--Projects-TG-Mod)
)
# StrictMode 1.0 only guards uninitialised variables. Deliberately NOT "Latest":
# 2.0+ turns every missing JSON property into a terminating error.
Set-StrictMode -Version 1.0
$ErrorActionPreference = "Stop"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
function Write-Header {
param([string]$Text)
$line = "=" * ($Text.Length + 4)
Write-Host ""
Write-Host $line -ForegroundColor Cyan
Write-Host " $Text" -ForegroundColor Cyan
Write-Host $line -ForegroundColor Cyan
Write-Host ""
}
function Write-Step { param([string]$Text); Write-Host ">>> $Text" -ForegroundColor Yellow }
function Write-OK { param([string]$Text); Write-Host " [OK] $Text" -ForegroundColor Green }
function Write-Warn { param([string]$Text); Write-Host " [WARN] $Text" -ForegroundColor DarkYellow }
function Write-Err { param([string]$Text); Write-Host " [ERR] $Text" -ForegroundColor Red }
function Write-Info { param([string]$Text); Write-Host " $Text" -ForegroundColor Gray }
function Confirm-Action {
param([string]$Prompt, [bool]$DefaultYes = $true)
if ($Yes) { return $true }
$hint = if ($DefaultYes) { "[Y/n]" } else { "[y/N]" }
Write-Host ""
$answer = Read-Host "$Prompt $hint"
if ($answer -eq "") { return $DefaultYes }
return $answer -match "^[yY]"
}
function Has-BOM {
param([string]$FilePath)
$fs = [System.IO.File]::OpenRead($FilePath)
try {
$b = New-Object byte[] 3
$read = $fs.Read($b, 0, 3)
return ($read -eq 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)
} finally { $fs.Dispose() }
}
function Strip-BOM {
param([string]$FilePath)
$b = [System.IO.File]::ReadAllBytes($FilePath)
if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) { $b = $b[3..($b.Length-1)] }
return [System.Text.Encoding]::UTF8.GetString($b)
}
function Is-ValidJson {
param([string]$Text)
try { $null = $Text | ConvertFrom-Json; return $true } catch { return $false }
}
# Safe property read - works regardless of StrictMode level.
function Get-Prop {
param($Object, [string]$Name)
if ($null -eq $Object) { return $null }
$p = $Object.PSObject.Properties[$Name]
if ($null -eq $p) { return $null }
return $p.Value
}
# Pull a compact-JSONL scalar without a full parse: "key":"value"
function Get-RawValue {
param([string]$Line, [string]$Key)
$needle = '"' + $Key + '":"'
$i = $Line.IndexOf($needle)
if ($i -lt 0) { return $null }
$s = $i + $needle.Length
$e = $Line.IndexOf('"', $s)
if ($e -le $s) { return $null }
return $Line.Substring($s, $e - $s)
}
$BoilerplateMarkers = @(
'<local-command-caveat>', '<system-reminder>', '<command-name>',
'<command-message>', '<command-args>', 'Caveat: The messages below'
)
function Is-Boilerplate {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return $true }
$head = $Text.Substring(0, [Math]::Min(300, $Text.Length))
foreach ($m in $BoilerplateMarkers) { if ($head.Contains($m)) { return $true } }
return $false
}
function Clean-Title {
param([string]$Text, [int]$Max = 70)
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
$t = $Text -replace "`r", "" -replace "`n", " " -replace "\s+", " "
$t = $t.Trim()
if ($t.Length -gt $Max) { $t = $t.Substring(0, $Max).TrimEnd() + "..." }
return $t
}
# ---------------------------------------------------------------------------
# Transcript metadata extraction
# ---------------------------------------------------------------------------
function Extract-JsonlMetadata {
param([string]$FilePath)
$firstTs = $null; $lastTs = $null
$cwd = $null; $model = $null
$customTitle = $null; $aiTitle = $null; $lastPrompt = $null; $firstUser = $null
$userTurns = 0
$reader = [System.IO.StreamReader]::new($FilePath, [System.Text.Encoding]::UTF8)
try {
while ($null -ne ($line = $reader.ReadLine())) {
if ($line.Length -lt 8) { continue }
# --- timestamps: whole file, so lastActivityAt is accurate -------------
$ts = Get-RawValue $line 'timestamp'
if ($ts) {
if (-not $firstTs) { $firstTs = $ts }
$lastTs = $ts
}
$isSide = $line.Contains('"isSidechain":true')
# --- model lives at message.model, never top-level --------------------
if (-not $isSide) {
$m = Get-RawValue $line 'model'
if ($m -and $m.StartsWith('claude-')) { $model = $m }
}
# --- cwd from the transcript itself (authoritative) -------------------
if (-not $cwd -and $line.Contains('"cwd":"')) {
try {
$o = $line | ConvertFrom-Json
$v = Get-Prop $o 'cwd'
if ($v) { $cwd = $v }
} catch {}
}
# --- real titles ------------------------------------------------------
if ($line.Contains('-title"')) {
try {
$o = $line | ConvertFrom-Json
switch (Get-Prop $o 'type') {
'custom-title' { $v = Get-Prop $o 'customTitle'; if ($v) { $customTitle = $v } }
'ai-title' { $v = Get-Prop $o 'aiTitle'; if ($v) { $aiTitle = $v } }
}
} catch {}
}
if (-not $lastPrompt -and $line.Contains('"type":"last-prompt"')) {
try {
$o = $line | ConvertFrom-Json
$v = Get-Prop $o 'lastPrompt'
if ($v -and -not (Is-Boilerplate $v)) { $lastPrompt = $v }
} catch {}
}
# --- real user turns carry a promptId; tool results do not ------------
if (-not $isSide -and $line.Contains('"type":"user"') -and $line.Contains('"promptId":"')) {
$userTurns++
if (-not $firstUser) {
try {
$o = $line | ConvertFrom-Json
$msg = Get-Prop $o 'message'
if ($msg -and (Get-Prop $msg 'role') -eq 'user') {
$c = Get-Prop $msg 'content'
$t = $null
if ($c -is [string]) { $t = $c }
elseif ($c -is [System.Array]) {
foreach ($part in $c) { $pt = Get-Prop $part 'text'; if ($pt) { $t = $pt; break } }
}
if ($t -and -not (Is-Boilerplate $t)) { $firstUser = $t }
}
} catch {}
}
}
}
} finally { $reader.Dispose() }
# --- title priority: user-set > AI-generated > prompt text > uuid ---------
$title = $null; $titleSource = 'auto'
if ($customTitle) { $title = Clean-Title $customTitle 120; $titleSource = 'custom' }
elseif ($aiTitle) { $title = Clean-Title $aiTitle 120 }
elseif ($lastPrompt) { $title = Clean-Title $lastPrompt 70 }
elseif ($firstUser) { $title = Clean-Title $firstUser 70 }
if (-not $title) { $title = "Session $([IO.Path]::GetFileNameWithoutExtension($FilePath).Substring(0,8))" }
if (-not $firstTs) { $firstTs = "2020-01-01T00:00:00.000Z" }
if (-not $lastTs) { $lastTs = $firstTs }
if (-not $model) { $model = "claude-sonnet-5" }
return @{
CreatedEpoch = [DateTimeOffset]::Parse($firstTs).ToUnixTimeMilliseconds()
LastActivityEpoch = [DateTimeOffset]::Parse($lastTs).ToUnixTimeMilliseconds()
Title = $title
TitleSource = $titleSource
Model = $model
Cwd = $cwd # $null => cannot place the session
UserTurns = $userTurns
}
}
function New-LocalSessionJson {
param(
[string]$CliSessionId, [string]$Cwd,
[long]$CreatedEpoch, [long]$LastActivityEpoch,
[string]$Title, [string]$TitleSource, [string]$Model, [int]$CompletedTurns
)
$localId = "local_" + [Guid]::NewGuid().ToString()
$obj = [ordered]@{
sessionId = $localId
cliSessionId = $CliSessionId
cwd = $Cwd
originCwd = $Cwd
lastFocusedAt = $LastActivityEpoch
createdAt = $CreatedEpoch
lastActivityAt = $LastActivityEpoch
model = $Model
effort = "high"
sessionSettings = [ordered]@{ ultracode = $false }
isArchived = $false
title = $Title
titleSource = $TitleSource
permissionMode = "default"
remoteMcpServersConfig = @()
completedTurns = $CompletedTurns
alwaysAllowedReasons = @()
sessionPermissionUpdates = @()
classifierSummaryEnabled = $true
reportFindingsCard = $false
spawnSeed = [ordered]@{}
}
return @{ Id = $localId; Json = ($obj | ConvertTo-Json -Depth 10 -Compress) }
}
# ---------------------------------------------------------------------------
# STEP 0 - Banner
# ---------------------------------------------------------------------------
Write-Host @"
Claude Code Desktop - Session Recovery Tool (fixed edition)
===========================================================
Restores CLI conversations into the Desktop app sidebar,
preserving their real titles, models, paths and timestamps.
"@ -ForegroundColor Cyan
if ($DryRun) { Write-Warn "DRY RUN - nothing will be written." }
# ---------------------------------------------------------------------------
# STEP 1 - Locate Claude directories
# ---------------------------------------------------------------------------
Write-Header "STEP 1: Detecting Claude installation"
$homeDir = $env:USERPROFILE
$claudeDir = Join-Path $homeDir ".claude\projects"
Write-Info "Running as : $env:USERNAME"
Write-Info "USERPROFILE : $homeDir"
Write-Info "APPDATA : $env:APPDATA"
# Claude Desktop ships as an MSIX (Store-style) package. Inside the app's
# container, writes to %APPDATA%\Claude are transparently redirected to the
# package's LocalCache. A PowerShell started OUTSIDE the container (from
# Explorer, Total Commander, the Start menu) does not see that virtual path -
# %APPDATA%\Claude simply does not exist there. So probe the real location too.
$appDataCandidates = @( (Join-Path $env:APPDATA "Claude") )
$pkgRoot = Join-Path $env:LOCALAPPDATA "Packages"
if (Test-Path $pkgRoot) {
foreach ($pkg in @(Get-ChildItem $pkgRoot -Directory -Filter "Claude_*" -ErrorAction SilentlyContinue)) {
$appDataCandidates += (Join-Path $pkg.FullName "LocalCache\Roaming\Claude")
}
}
$appDataDir = $null
# Prefer a candidate that actually holds the session store.
foreach ($cand in $appDataCandidates) {
if (Test-Path (Join-Path $cand "claude-code-sessions")) { $appDataDir = $cand; break }
}
if (-not $appDataDir) { foreach ($cand in $appDataCandidates) { if (Test-Path $cand) { $appDataDir = $cand; break } } }
if (-not $appDataDir) { $appDataDir = $appDataCandidates[0] }
$sessionsRoot = Join-Path $appDataDir "claude-code-sessions"
$configFile = Join-Path $appDataDir "config.json"
if ($appDataDir -like "*\Packages\Claude_*") {
Write-Info "MSIX container: resolved real path outside the app sandbox"
}
Write-Host ""
$fatal = $false
foreach ($check in @(
@{ Path = $claudeDir; Label = "~/.claude/projects (CLI history)" },
@{ Path = $appDataDir; Label = "%APPDATA%\Claude (Desktop app)" },
@{ Path = $sessionsRoot; Label = "claude-code-sessions folder" }
)) {
if (Test-Path $check.Path) { Write-OK "$($check.Label) -> $($check.Path)" }
else { Write-Err "$($check.Label) NOT FOUND: $($check.Path)"; $fatal = $true }
}
if ($fatal) {
Write-Host ""
Write-Err "Cannot continue."
Write-Info "Claude Desktop is an MSIX package; its real store lives under"
Write-Info " %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude"
Write-Info "This script probes both that path and %APPDATA%\Claude. If neither"
Write-Info "was found: check you are running as yourself and NOT elevated, and"
Write-Info "launch Claude Desktop once (start a session, quit) before retrying."
if (-not $Yes) { Read-Host "Press Enter to exit" }
exit 1
}
$accountId = $null; $orgId = $null
if (Test-Path $configFile) {
try {
$cfg = Get-Content $configFile -Raw | ConvertFrom-Json
$accountId = Get-Prop $cfg 'lastKnownAccountUuid'
} catch { Write-Warn "Could not parse config.json: $_" }
}
if (-not $accountId) { Write-Err "Account ID not found. Log in to Claude Code Desktop first."; if(-not $Yes){Read-Host}; exit 1 }
$acctDir = Join-Path $sessionsRoot $accountId
if (Test-Path $acctDir) {
$orgDirs = @(Get-ChildItem $acctDir -Directory -ErrorAction SilentlyContinue)
if ($orgDirs.Count -eq 1) { $orgId = $orgDirs[0].Name }
elseif ($orgDirs.Count -gt 1) {
# Prefer the org folder that already holds the most registrations.
$orgId = ($orgDirs | Sort-Object { @(Get-ChildItem "$($_.FullName)\local_*.json" -ErrorAction SilentlyContinue).Count } -Descending)[0].Name
Write-Warn "Multiple org folders found; using the most populated: $orgId"
}
}
if (-not $orgId) { Write-Err "Org ID not found. Start at least one session in Claude Code Desktop."; if(-not $Yes){Read-Host}; exit 1 }
Write-OK "Account ID : $accountId"
Write-OK "Org ID : $orgId"
$registrationDir = Join-Path $sessionsRoot "$accountId\$orgId"
Write-OK "Registration dir: $registrationDir"
# ---------------------------------------------------------------------------
# STEP 2 - Pre-check: BOM corruption scan
# ---------------------------------------------------------------------------
Write-Header "STEP 2: Pre-check - scanning existing registration files"
$existingFiles = @(Get-ChildItem "$registrationDir\local_*.json" -ErrorAction SilentlyContinue)
$bomFiles = [System.Collections.Generic.List[string]]::new()
Write-Step "Scanning $($existingFiles.Count) existing local_*.json files..."
foreach ($f in $existingFiles) { if (Has-BOM $f.FullName) { $bomFiles.Add($f.FullName) } }
if ($bomFiles.Count -eq 0) {
Write-OK "All $($existingFiles.Count) existing files are clean - no BOM corruption."
} else {
Write-Warn "$($bomFiles.Count) file(s) have a UTF-8 BOM prefix (breaks JSON parsing in the app):"
foreach ($fp in $bomFiles) { Write-Info " $([IO.Path]::GetFileName($fp))" }
if (-not $DryRun -and (Confirm-Action "Fix $($bomFiles.Count) corrupted file(s) by rewriting without BOM?")) {
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$fixedCount = 0
foreach ($fp in $bomFiles) {
$cleaned = Strip-BOM $fp
if (Is-ValidJson $cleaned) { [System.IO.File]::WriteAllText($fp, $cleaned, $utf8NoBom); $fixedCount++ }
else { Write-Warn "Skipped $([IO.Path]::GetFileName($fp)) - invalid JSON even after BOM removal." }
}
Write-OK "Fixed $fixedCount file(s)."
}
}
# ---------------------------------------------------------------------------
# STEP 3 - Discover projects
# ---------------------------------------------------------------------------
Write-Header "STEP 3: Discovering projects in ~/.claude/projects"
$projectDirs = @(Get-ChildItem $claudeDir -Directory -ErrorAction SilentlyContinue)
if ($Project) { $projectDirs = @($projectDirs | Where-Object { $_.Name -eq $Project }) }
if ($projectDirs.Count -eq 0) { Write-Warn "No project directories found."; if(-not $Yes){Read-Host}; exit 0 }
$registeredCliIds = [System.Collections.Generic.HashSet[string]]::new()
foreach ($f in $existingFiles) {
try {
$obj = Get-Content $f.FullName -Raw | ConvertFrom-Json
$cid = Get-Prop $obj 'cliSessionId'
if ($cid) { [void]$registeredCliIds.Add($cid) }
} catch {}
}
Write-Info "Currently registered sessions in app: $($registeredCliIds.Count)"
$projects = [System.Collections.Generic.List[hashtable]]::new()
foreach ($dir in $projectDirs) {
$jsonls = @(Get-ChildItem "$($dir.FullName)\*.jsonl" -ErrorAction SilentlyContinue)
$missing = @($jsonls | Where-Object { -not $registeredCliIds.Contains($_.BaseName) })
$projects.Add(@{
FolderName = $dir.Name; FullPath = $dir.FullName
TotalCount = $jsonls.Count; Missing = $missing; MissingCount = $missing.Count
})
}
Write-Host "`n Found $($projects.Count) project(s):" -ForegroundColor White
$idx = 1
foreach ($p in $projects) {
$status = if ($p.MissingCount -eq 0) { "[UP TO DATE]" } elseif ($p.MissingCount -eq $p.TotalCount) { "[ALL MISSING]" } else { "[$($p.MissingCount) MISSING]" }
$color = if ($p.MissingCount -eq 0) { "Green" } elseif ($p.MissingCount -eq $p.TotalCount) { "Red" } else { "Yellow" }
Write-Host (" {0,2}. {1,-48} {2,4} sessions {3}" -f $idx, $p.FolderName, $p.TotalCount, $status) -ForegroundColor $color
$idx++
}
# NB: do not use `Measure-Object -Property` here - in PowerShell 5.1 it cannot
# read keys off a [hashtable] and throws GenericMeasurePropertyNotFound.
$totalMissing = 0
foreach ($p in $projects) { $totalMissing += [int]$p.MissingCount }
if (-not $totalMissing) {
Write-Host ""; Write-OK "Nothing to do - all sessions are already registered!"
if (-not $Yes) { Read-Host "Press Enter to exit" }
exit 0
}
Write-Host "`n Total missing sessions: $totalMissing" -ForegroundColor Yellow
# ---------------------------------------------------------------------------
# STEP 4 - Select projects
# ---------------------------------------------------------------------------
$selectedProjects = [System.Collections.Generic.List[hashtable]]::new()
if ($Yes -or $Project) {
foreach ($p in $projects) { if ($p.MissingCount -gt 0) { $selectedProjects.Add($p) } }
} else {
Write-Header "STEP 4: Select projects to restore"
Write-Host " A = restore ALL projects with missing sessions"
Write-Host " 1,2,3,... = restore specific projects (comma-separated numbers)"
Write-Host " Q = quit without changes"
Write-Host ""
$selection = Read-Host "Your choice"
if ($selection -match "^[qQ]$") { Write-Host "Aborted." -ForegroundColor Gray; exit 0 }
if ($selection -match "^[aA]$") {
foreach ($p in $projects) { if ($p.MissingCount -gt 0) { $selectedProjects.Add($p) } }
} else {
foreach ($i in ($selection -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" })) {
$n = [int]$i
if ($n -ge 1 -and $n -le $projects.Count) {
$p = $projects[$n-1]
if ($p.MissingCount -gt 0) { $selectedProjects.Add($p) } else { Write-Warn "Project $n has no missing sessions." }
} else { Write-Warn "Invalid index: $n" }
}
}
}
if ($selectedProjects.Count -eq 0) { Write-Host "No valid projects selected." -ForegroundColor Gray; exit 0 }
# ---------------------------------------------------------------------------
# STEP 5 - Read transcripts, then write registrations
# ---------------------------------------------------------------------------
Write-Header "STEP 5: Reading transcripts"
$plan = [System.Collections.Generic.List[hashtable]]::new()
$skipped = [System.Collections.Generic.List[string]]::new()
foreach ($p in $selectedProjects) {
Write-Step "$($p.FolderName) - $($p.MissingCount) transcript(s)"
foreach ($f in $p.Missing) {
try {
$meta = Extract-JsonlMetadata -FilePath $f.FullName
if (-not $meta.Cwd) {
$skipped.Add("$($f.BaseName.Substring(0,8)) - no cwd recorded in transcript")
continue
}
if ($meta.UserTurns -eq 0) {
$skipped.Add("$($f.BaseName.Substring(0,8)) - empty session (no user turns)")
continue
}
$plan.Add(@{ File = $f; Meta = $meta })
$when = [DateTimeOffset]::FromUnixTimeMilliseconds($meta.LastActivityEpoch).ToLocalTime().ToString("yyyy-MM-dd HH:mm")
Write-Info ("{0} {1,-16} {2}" -f $when, $meta.Model.Replace('claude-',''), $meta.Title)
} catch { Write-Err "Failed reading $($f.Name): $_" }
}
}
if ($skipped.Count -gt 0) {
Write-Host ""
Write-Warn "Skipping $($skipped.Count) transcript(s):"
foreach ($s in $skipped) { Write-Info " $s" }
}
Write-Host ""
Write-Host " Ready to register $($plan.Count) session(s) into:" -ForegroundColor White
Write-Info " $registrationDir"
if ($DryRun) { Write-Host ""; Write-OK "Dry run complete - nothing written."; exit 0 }
if (-not (Confirm-Action "Proceed with creating $($plan.Count) registration file(s)?")) {
Write-Host "Aborted. No changes made." -ForegroundColor Gray; exit 0
}
Write-Header "Writing registrations"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$done = 0; $fail = 0
foreach ($item in $plan) {
try {
$meta = $item.Meta
$result = New-LocalSessionJson -CliSessionId $item.File.BaseName -Cwd $meta.Cwd `
-CreatedEpoch $meta.CreatedEpoch -LastActivityEpoch $meta.LastActivityEpoch `
-Title $meta.Title -TitleSource $meta.TitleSource -Model $meta.Model `
-CompletedTurns $meta.UserTurns
$outPath = Join-Path $registrationDir "$($result.Id).json"
[System.IO.File]::WriteAllText($outPath, $result.Json, $utf8NoBom)
$done++
} catch { Write-Err "Failed $($item.File.Name): $_"; $fail++ }
}
# ---------------------------------------------------------------------------
# STEP 6 - Summary
# ---------------------------------------------------------------------------
Write-Header "Done!"
Write-Host " Created : $done session registration file(s)" -ForegroundColor Green
if ($fail -gt 0) { Write-Host " Failed : $fail file(s) - see errors above" -ForegroundColor Red }
if ($skipped.Count -gt 0) { Write-Host " Skipped : $($skipped.Count) file(s)" -ForegroundColor DarkYellow }
Write-Host ""
Write-Host " Next steps:" -ForegroundColor White
Write-Host " 1. Fully quit Claude Code Desktop (including from the system tray)" -ForegroundColor Gray
Write-Host " 2. Reopen Claude Code Desktop" -ForegroundColor Gray
Write-Host " 3. Your sessions should now appear in the sidebar, correctly titled" -ForegroundColor Gray
Write-Host ""
if (-not $Yes) { Read-Host "Press Enter to exit" }
@ayankhan5004

Copy link
Copy Markdown

Finally, this version fixed everything! πŸŽ‰

All my sessions are now restored in the sidebar with their original names/titles. Everything is working perfectly.

Huge thanks to @XPOL555 for the original script and the initial approach, and especially to @n3tman for the fixed version and all the improvements. πŸ™

Really appreciate the work you both put into this. This saved my Claude Code sessions and made the recovery process much more reliable.

Thank you both! ❀️

@n3tman

n3tman commented Aug 17, 2026

Copy link
Copy Markdown
Author

@ayankhan5004 I'm glad it worked for you too! πŸ˜„

I knew people would find it useful. I hope Claude fixes this issue on their side so scripts like this won't be needed.

@Backbender69

Copy link
Copy Markdown

Bro you saved my life. Lost all my sessions yesterday, and this repo got it back. You are the goat!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment