|
#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" } |
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! β€οΈ