|
#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" |
|
|
|
|
|
|
|
|
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 "[1]" |
|
} |
|
|
|
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) |
|
|
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) } |
|
} |
|
|
|
|
|
|
|
|
Clear-Host |
|
Write-Host @" |
|
Claude Code Desktop - Session Recovery Tool |
|
============================================ |
|
Recovers conversations from the CLI history into the Desktop app sidebar. |
|
"@ -ForegroundColor Cyan |
|
|
|
|
|
|
|
|
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" | | | | | | | | | 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." | | } | | } | | | | | | | | | 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 | | | | | | | | | 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 |
|
|
|
|
|
|
|
|
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_<uuid>.json -> $($f.BaseName)" } |
|
} |
|
|
|
if (-not (Confirm-Action "Proceed with creating $selectedMissing registration file(s)?")) { |
|
Write-Host "Aborted. No changes made." -ForegroundColor Gray; exit 0 |
|
} |
|
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding $false |
|
$done = 0; $fail = 0 |
|
|
|
foreach ($p in $selectedProjects) { |
|
Write-Step "Restoring: $($p.FolderName)" |
|
foreach ($f in $p.Missing) { |
|
try { |
|
$meta = Extract-JsonlMetadata -FilePath $f.FullName -FallbackCwd $p.Cwd |
|
$result = New-LocalSessionJson -CliSessionId $f.BaseName -Cwd $meta.Cwd | | -CreatedEpoch $meta.CreatedEpoch -LastActivityEpoch $meta.LastActivityEpoch |
|
-Title $meta.Title -Model $meta.Model |
|
$outPath = Join-Path $registrationDir "$($result.Id).json" |
|
[System.IO.File]::WriteAllText($outPath, $result.Json, $utf8NoBom) |
|
$shortTitle = $meta.Title.Substring(0, [Math]::Min(60, $meta.Title.Length)) |
|
Write-OK "$($result.Id) | $shortTitle" |
|
$done++ |
|
} catch { Write-Err "Failed $($f.Name): $_"; $fail++ } |
|
} |
|
} |
|
|
|
|
|
|
|
|
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 } |
|
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" -ForegroundColor Gray |
|
Write-Host "" |
|
Read-Host "Press Enter to exit" |
yY ↩︎