Recover Claude Code Desktop sidebar sessions from CLI history (Windows) A developer created a PowerShell script to recover Claude Code Desktop sessions that are missing from the sidebar on Windows. The script scans ~/.claude/projects/ for .jsonl files and generates local_*.json registration files so the app displays them. It extracts metadata like timestamps, model, and working directory from the JSONL files to reconstruct session entries. | Requires -Version 5.1 | | < | | .SYNOPSIS | | Recover Claude Code Desktop sessions missing from the sidebar. | | .DESCRIPTION | | Creates local .json registration files so Claude Code Desktop shows | | sessions that exist as .jsonl files in ~/.claude/projects/ but are | | not visible in the app sidebar. | | .NOTES | | Usage: powershell -ExecutionPolicy Bypass -File restore-claude-sessions.ps1 | | | | | | Set-StrictMode -Version Latest | | $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 | | $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 | | $b = System.IO.File ::ReadAllBytes $FilePath | | return $b.Length -ge 3 -and $b 0 -eq 0xEF -and $b 1 -eq 0xBB -and $b 2 -eq 0xBF | | } | | | | function Strip-BOM { | | param string $FilePath | | $b = System.IO.File ::ReadAllBytes $FilePath | | if $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 } | | } | | | | function Decode-ProjectPath { | | param string $FolderName | | C--Users-paolo-Projects-myapp - C:\Users\paolo\Projects\myapp | | if $FolderName -match "^ A-Za-z -- .+ $" { | | $drive = $Matches 1 | | $rest = $Matches 2 -replace "--", "\" | | return "${drive}:\${rest}" | | } | | return $FolderName | | } | | | | function Extract-JsonlMetadata { | | param string $FilePath, string $FallbackCwd | | $timestamps = System.Collections.Generic.List string ::new | | $firstUserContent = $null | | $model = "claude-sonnet-5" | | $cwd = $FallbackCwd | | | | $reader = System.IO.StreamReader ::new $FilePath, System.Text.Encoding ::UTF8 | | $n = 0 | | while -not $reader.EndOfStream -and $n -lt 200 { | | $line = $reader.ReadLine ; $n++ | | try { | | $obj = $line | ConvertFrom-Json -ErrorAction SilentlyContinue | | if $null -eq $obj { continue } | | if $obj.timestamp { $timestamps.Add $obj.timestamp } | | if $obj.model -and $obj.model -ne "" { $model = $obj.model } | | if $obj.cwd -and $obj.cwd -ne "" { $cwd = $obj.cwd } | | if -not $firstUserContent -and $obj.message -and $obj.message.role -eq "user" { | | $c = $obj.message.content | | if $c -is string -and $c.Length -gt 0 { $firstUserContent = $c.Substring 0, Math ::Min 70,$c.Length } | | elseif $c -is array -and $c.Count -gt 0 -and $c 0 .text { $firstUserContent = $c 0 .text.Substring 0, Math ::Min 70,$c 0 .text.Length } | | } | | } catch {} | | } | | $reader.Close | | | | $sorted = $timestamps | Sort-Object | | $createdAt = if $sorted.Count -gt 0 { $sorted 0 } else { "2020-01-01T00:00:00.000Z" } | | $lastAt = if $sorted.Count -gt 0 { $sorted -1 } else { $createdAt } | | $title = if $firstUserContent { $firstUserContent -replace " n"," " -replace " r","" .Trim } else { "Session $ IO.Path ::GetFileNameWithoutExtension $FilePath " } | | | | return @{ | | CreatedEpoch = DateTimeOffset ::Parse $createdAt .ToUnixTimeMilliseconds | | LastActivityEpoch = DateTimeOffset ::Parse $lastAt .ToUnixTimeMilliseconds | | Title = $title; Model = $model; Cwd = $cwd | | } | | } | | | | function New-LocalSessionJson { | | param string $CliSessionId, string $Cwd, long $CreatedEpoch, long $LastActivityEpoch, string $Title, string $Model | | $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"; isArchived=$false; title="$Title"; titleSource="auto" | | permissionMode="default"; remoteMcpServersConfig=@ ; alwaysAllowedReasons=@ | | sessionPermissionUpdates=@ ; classifierSummaryEnabled=$true; reportFindingsCard=$false | | spawnSeed= ordered @{} | | } | | return @{ Id=$localId; Json= $obj | ConvertTo-Json -Compress } | | } | | | | --------------------------------------------------------------------------- | | STEP 0 - Banner | | --------------------------------------------------------------------------- | | | | Clear-Host | | Write-Host @" | | Claude Code Desktop - Session Recovery Tool | | ============================================ | | Recovers conversations from the CLI history into the Desktop app sidebar. | | "@ -ForegroundColor Cyan | | | | --------------------------------------------------------------------------- | | STEP 1 - Locate Claude directories | | --------------------------------------------------------------------------- | | | | Write-Header "STEP 1: Detecting Claude installation" | | | | $homeDir = $env:USERPROFILE | | $claudeDir = Join-Path $homeDir ".claude\projects" | | $appDataDir = Join-Path $env:APPDATA "Claude" | | $sessionsRoot = Join-Path $appDataDir "claude-code-sessions" | | $configFile = Join-Path $appDataDir "config.json" | | | | $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 " nCannot continue. Make sure Claude Code Desktop is installed and launched at least once." -ForegroundColor Red | | 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 = $cfg.lastKnownAccountUuid | | $acctDir = Join-Path $sessionsRoot $accountId | | if Test-Path $acctDir { | | $orgDirs = Get-ChildItem $acctDir -Directory | | if $orgDirs.Count -ge 1 { $orgId = $orgDirs 0 .Name } | | } | | } catch { Write-Warn "Could not parse config.json: $ " } | | } | | | | if -not $accountId { Write-Err "Account ID not found. Log in to Claude Code Desktop first."; Read-Host; exit 1 } | | if -not $orgId { Write-Err "Org ID not found. Start at least one session in Claude Code Desktop."; 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: 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 corruption found." | | } else { | | Write-Warn "$ $bomFiles.Count file s have a UTF-8 BOM prefix causes JSON parse errors in the app :" | | foreach $fp in $bomFiles { Write-Info " $ IO.Path ::GetFileName $fp " } | | | | if 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 - not valid JSON even after BOM removal." | | } | | } | | Write-OK "Fixed $fixedCount file s ." | | } else { | | Write-Warn "Skipped BOM fix. The app may still fail to load those sessions." | | } | | } | | | | --------------------------------------------------------------------------- | | STEP 3 - Discover projects | | --------------------------------------------------------------------------- | | | | Write-Header "STEP 3: Discovering projects in ~/.claude/projects" | | | | $projectDirs = @ Get-ChildItem $claudeDir -Directory -ErrorAction SilentlyContinue | | if $projectDirs.Count -eq 0 { | | Write-Warn "No project directories found."; Read-Host; exit 0 | | } | | | | $registeredCliIds = System.Collections.Generic.HashSet string ::new | | foreach $f in @ Get-ChildItem "$registrationDir\local .json" -ErrorAction SilentlyContinue { | | try { | | $obj = Get-Content $f.FullName -Raw | ConvertFrom-Json | | if $obj.cliSessionId { void $registeredCliIds.Add $obj.cliSessionId } | | } 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 } | | $cwd = Decode-ProjectPath $dir.Name | | $projects.Add @{ FolderName=$dir.Name; FullPath=$dir.FullName; Cwd=$cwd; 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 $status -eq " UP TO DATE " { "Green" } elseif $status -eq " ALL MISSING " { "Red" } else { "Yellow" } | | Write-Host " {0,2}. {1,-48} {2,3} sessions {3}" -f $idx, $p.FolderName, $p.TotalCount, $status -ForegroundColor $color | | Write-Info " - $ $p.Cwd " | | $idx++ | | } | | | | $totalMissing = $projects | Measure-Object -Property MissingCount -Sum .Sum | | if $totalMissing -eq 0 { | | Write-Host ""; Write-OK "Nothing to do - all sessions are already registered " | | Read-Host "Press Enter to exit"; exit 0 | | } | | Write-Host " n Total missing sessions: $totalMissing" -ForegroundColor Yellow | | | | --------------------------------------------------------------------------- | | STEP 4 - Select projects | | --------------------------------------------------------------------------- | | | | 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 } | | | | $selectedProjects = System.Collections.Generic.List hashtable ::new | | 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 } | | $selectedMissing = $selectedProjects | Measure-Object -Property MissingCount -Sum .Sum | | Write-Host " n Selected $ $selectedProjects.Count project s - $selectedMissing sessions to restore." -ForegroundColor White | | | | --------------------------------------------------------------------------- | | STEP 5 - Confirm and execute | | --------------------------------------------------------------------------- | | | | Write-Header "STEP 5: Restore missing sessions" | | Write-Host " Files will be created in: $registrationDir" -ForegroundColor Gray | | Write-Host "" | | | | foreach $p in $selectedProjects { | | Write-Host " Project: $ $p.FolderName $ $p.MissingCount to create " -ForegroundColor White | | foreach $f in $p.Missing { Write-Info " local