Skip to main content

chasm/commands/
register.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Register commands - Add sessions to VS Code's session index
4//!
5//! VS Code only displays sessions that are registered in the `chat.ChatSessionStore.index`
6//! stored in `state.vscdb`. Sessions can exist on disk but be invisible to VS Code if
7//! they're not in this index. These commands help register orphaned sessions.
8
9use anyhow::Result;
10use colored::*;
11use std::collections::HashSet;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use crate::error::CsmError;
16use crate::models::ChatSession;
17use crate::storage::{
18    add_session_to_index, close_vscode_and_wait, diagnose_workspace_sessions,
19    get_workspace_storage_db, is_session_file_extension, is_vscode_running, parse_session_file,
20    parse_session_json, read_chat_session_index, register_all_sessions_from_directory,
21    reopen_vscode, repair_workspace_sessions,
22};
23use crate::workspace::{discover_workspaces, find_workspace_by_path, normalize_path};
24
25/// Prompt the user to confirm closing VS Code. Returns true if confirmed, false if declined.
26/// When `force` is true, skips the prompt and returns true immediately.
27fn confirm_close_vscode(force: bool) -> bool {
28    if force {
29        return true;
30    }
31    print!(
32        "{} VS Code will be closed. Continue? [y/N] ",
33        "[?]".yellow()
34    );
35    std::io::stdout().flush().ok();
36    let mut input = String::new();
37    if std::io::stdin().read_line(&mut input).is_err() {
38        return false;
39    }
40    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
41}
42
43/// Resolve a path option to an absolute PathBuf, handling "." and relative paths
44pub fn resolve_path(path: Option<&str>) -> PathBuf {
45    match path {
46        Some(p) => {
47            let path = PathBuf::from(p);
48            path.canonicalize().unwrap_or(path)
49        }
50        None => std::env::current_dir().unwrap_or_default(),
51    }
52}
53
54/// Register all sessions from a workspace into VS Code's index
55pub fn register_all(
56    project_path: Option<&str>,
57    merge: bool,
58    force: bool,
59    close_vscode: bool,
60    reopen: bool,
61) -> Result<()> {
62    let path = resolve_path(project_path);
63    // --reopen implies --close-vscode
64    let should_close = close_vscode || reopen;
65
66    if merge {
67        println!(
68            "{} Merging and registering all sessions for: {}",
69            "[CSM]".cyan().bold(),
70            path.display()
71        );
72
73        // Use the existing merge functionality
74        let path_str = path.to_string_lossy().to_string();
75        return crate::commands::history_merge(
76            Some(&path_str),
77            None,  // title
78            force, // force
79            false, // no_backup
80        );
81    }
82
83    println!(
84        "{} Registering all sessions for: {}",
85        "[CSM]".cyan().bold(),
86        path.display()
87    );
88
89    // Find the workspace
90    let path_str = path.to_string_lossy().to_string();
91    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
92        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
93
94    let chat_sessions_dir = ws_path.join("chatSessions");
95
96    if !chat_sessions_dir.exists() {
97        println!(
98            "{} No chatSessions directory found at: {}",
99            "[!]".yellow(),
100            chat_sessions_dir.display()
101        );
102        return Ok(());
103    }
104
105    // Handle VS Code lifecycle
106    let vscode_was_running = is_vscode_running();
107    if vscode_was_running {
108        if should_close {
109            if !confirm_close_vscode(force) {
110                println!("{} Aborted.", "[!]".yellow());
111                return Ok(());
112            }
113            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
114            close_vscode_and_wait(30)?;
115            println!("   {} VS Code closed.", "[OK]".green());
116        } else if !force {
117            println!(
118                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
119                "[!]".yellow()
120            );
121            println!(
122                "   Use {} to close VS Code first, register, and reopen.",
123                "--reopen".cyan()
124            );
125            println!(
126                "   Use {} to just close VS Code first.",
127                "--close-vscode".cyan()
128            );
129            println!(
130                "   Use {} to write anyway (works after restarting VS Code).",
131                "--force".cyan()
132            );
133            return Err(CsmError::VSCodeRunning.into());
134        }
135    }
136
137    // Count sessions on disk
138    let sessions_on_disk = count_sessions_in_directory(&chat_sessions_dir)?;
139    println!(
140        "   Found {} session files on disk",
141        sessions_on_disk.to_string().green()
142    );
143
144    // Register all sessions
145    let registered = register_all_sessions_from_directory(&ws_id, &chat_sessions_dir, true)?;
146
147    println!(
148        "\n{} Registered {} sessions in VS Code's index",
149        "[OK]".green().bold(),
150        registered.to_string().cyan()
151    );
152
153    // Reopen VS Code if requested (or if we closed it with --reopen)
154    if reopen && vscode_was_running {
155        println!("   {} Reopening VS Code...", "[*]".yellow());
156        reopen_vscode(Some(&path_str))?;
157        println!(
158            "   {} VS Code launched. Sessions should appear in Copilot Chat history.",
159            "[OK]".green()
160        );
161    } else if should_close && vscode_was_running {
162        println!(
163            "\n{} VS Code was closed. Reopen it to see the recovered sessions.",
164            "[!]".yellow()
165        );
166        println!("   Run: {}", format!("code {}", path.display()).cyan());
167    } else if force && vscode_was_running {
168        // VS Code is still running with --force, show reload instructions
169        println!(
170            "\n{} VS Code caches the session index in memory.",
171            "[!]".yellow()
172        );
173        println!("   To see the new sessions, do one of the following:");
174        println!(
175            "   * Press {} and run {}",
176            "Ctrl+Shift+P".cyan(),
177            "Developer: Reload Window".cyan()
178        );
179        println!("   * Or restart VS Code");
180    }
181
182    Ok(())
183}
184
185/// Register specific sessions by ID or title
186pub fn register_sessions(
187    ids: &[String],
188    titles: Option<&[String]>,
189    project_path: Option<&str>,
190    force: bool,
191) -> Result<()> {
192    let path = resolve_path(project_path);
193
194    // Find the workspace
195    let path_str = path.to_string_lossy().to_string();
196    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
197        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
198
199    let chat_sessions_dir = ws_path.join("chatSessions");
200
201    // Check if VS Code is running
202    if !force && is_vscode_running() {
203        println!(
204            "{} VS Code is running. Use {} to register anyway.",
205            "[!]".yellow(),
206            "--force".cyan()
207        );
208        return Err(CsmError::VSCodeRunning.into());
209    }
210
211    // Get the database path
212    let db_path = get_workspace_storage_db(&ws_id)?;
213
214    let mut registered_count = 0;
215
216    if let Some(titles) = titles {
217        // Register by title
218        println!(
219            "{} Registering {} sessions by title:",
220            "[CSM]".cyan().bold(),
221            titles.len()
222        );
223
224        let sessions = find_sessions_by_titles(&chat_sessions_dir, titles)?;
225
226        for (session, session_path) in sessions {
227            let session_id = session.session_id.clone().unwrap_or_else(|| {
228                session_path
229                    .file_stem()
230                    .map(|s| s.to_string_lossy().to_string())
231                    .unwrap_or_default()
232            });
233            let title = session.title();
234
235            add_session_to_index(
236                &db_path,
237                &session_id,
238                &title,
239                session.last_message_date,
240                session.is_imported,
241                &session.initial_location,
242                session.is_empty(),
243            )?;
244
245            let id_display = if session_id.len() > 12 {
246                &session_id[..12]
247            } else {
248                &session_id
249            };
250            println!(
251                "   {} {} (\"{}\")",
252                "[OK]".green(),
253                id_display.cyan(),
254                title.yellow()
255            );
256            registered_count += 1;
257        }
258    } else {
259        // Register by ID (default)
260        println!(
261            "{} Registering {} sessions by ID:",
262            "[CSM]".cyan().bold(),
263            ids.len()
264        );
265
266        for session_id in ids {
267            match find_session_file(&chat_sessions_dir, session_id) {
268                Ok(session_file) => {
269                    let content = std::fs::read_to_string(&session_file)?;
270                    let session: ChatSession = serde_json::from_str(&content)?;
271
272                    let title = session.title();
273                    let actual_session_id = session
274                        .session_id
275                        .clone()
276                        .unwrap_or_else(|| session_id.to_string());
277
278                    add_session_to_index(
279                        &db_path,
280                        &actual_session_id,
281                        &title,
282                        session.last_message_date,
283                        session.is_imported,
284                        &session.initial_location,
285                        session.is_empty(),
286                    )?;
287
288                    let id_display = if actual_session_id.len() > 12 {
289                        &actual_session_id[..12]
290                    } else {
291                        &actual_session_id
292                    };
293                    println!(
294                        "   {} {} (\"{}\")",
295                        "[OK]".green(),
296                        id_display.cyan(),
297                        title.yellow()
298                    );
299                    registered_count += 1;
300                }
301                Err(e) => {
302                    println!(
303                        "   {} {} - {}",
304                        "[ERR]".red(),
305                        session_id.cyan(),
306                        e.to_string().red()
307                    );
308                }
309            }
310        }
311    }
312
313    println!(
314        "\n{} Registered {} sessions in VS Code's index",
315        "[OK]".green().bold(),
316        registered_count.to_string().cyan()
317    );
318
319    if force && is_vscode_running() {
320        println!(
321            "   {} Sessions should appear in VS Code immediately",
322            "->".cyan()
323        );
324    }
325
326    Ok(())
327}
328
329/// List sessions that exist on disk but are not in VS Code's index
330pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
331    let path = resolve_path(project_path);
332
333    println!(
334        "{} Finding orphaned sessions for: {}",
335        "[CSM]".cyan().bold(),
336        path.display()
337    );
338
339    // Find the workspace
340    let path_str = path.to_string_lossy().to_string();
341    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
342        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
343
344    let chat_sessions_dir = ws_path.join("chatSessions");
345
346    if !chat_sessions_dir.exists() {
347        println!("{} No chatSessions directory found", "[!]".yellow());
348        return Ok(());
349    }
350
351    // Get sessions currently in the index
352    let db_path = get_workspace_storage_db(&ws_id)?;
353    let index = read_chat_session_index(&db_path)?;
354    let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
355
356    println!(
357        "   {} sessions currently in VS Code's index",
358        indexed_ids.len().to_string().cyan()
359    );
360
361    // Find sessions on disk
362    let mut orphaned_sessions = Vec::new();
363
364    // Collect files, preferring .jsonl over .json for the same session ID
365    let mut session_files: std::collections::HashMap<String, PathBuf> =
366        std::collections::HashMap::new();
367    for entry in std::fs::read_dir(&chat_sessions_dir)? {
368        let entry = entry?;
369        let path = entry.path();
370        if path
371            .extension()
372            .map(is_session_file_extension)
373            .unwrap_or(false)
374        {
375            if let Some(stem) = path.file_stem() {
376                let stem_str = stem.to_string_lossy().to_string();
377                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
378                if !session_files.contains_key(&stem_str) || is_jsonl {
379                    session_files.insert(stem_str, path);
380                }
381            }
382        }
383    }
384
385    for (_, path) in &session_files {
386        if let Ok(session) = parse_session_file(path) {
387            let session_id = session.session_id.clone().unwrap_or_else(|| {
388                path.file_stem()
389                    .map(|s| s.to_string_lossy().to_string())
390                    .unwrap_or_default()
391            });
392
393            if !indexed_ids.contains(&session_id) {
394                let title = session.title();
395                let msg_count = session.requests.len();
396                orphaned_sessions.push((session_id, title, msg_count, path.clone()));
397            }
398        }
399    }
400
401    if orphaned_sessions.is_empty() {
402        println!(
403            "\n{} No orphaned sessions found - all sessions are registered!",
404            "[OK]".green().bold()
405        );
406        return Ok(());
407    }
408
409    println!(
410        "\n{} Found {} orphaned sessions (on disk but not in index):\n",
411        "[!]".yellow().bold(),
412        orphaned_sessions.len().to_string().red()
413    );
414
415    for (session_id, title, msg_count, _path) in &orphaned_sessions {
416        let id_display = if session_id.len() > 12 {
417            &session_id[..12]
418        } else {
419            session_id
420        };
421        println!(
422            "   {} {} ({} messages)",
423            id_display.cyan(),
424            format!("\"{}\"", title).yellow(),
425            msg_count
426        );
427    }
428
429    println!("\n{} To register all orphaned sessions:", "->".cyan());
430    println!("   csm register all --force");
431    println!("\n{} To register specific sessions:", "->".cyan());
432    println!("   csm register session <ID1> <ID2> ... --force");
433
434    Ok(())
435}
436
437/// Count session files in a directory (counts unique session IDs, preferring .jsonl)
438fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
439    let mut session_ids: HashSet<String> = HashSet::new();
440    for entry in std::fs::read_dir(dir)? {
441        let entry = entry?;
442        let path = entry.path();
443        if path
444            .extension()
445            .map(is_session_file_extension)
446            .unwrap_or(false)
447        {
448            if let Some(stem) = path.file_stem() {
449                session_ids.insert(stem.to_string_lossy().to_string());
450            }
451        }
452    }
453    Ok(session_ids.len())
454}
455
456/// Find a session file by ID (supports partial matches, prefers .jsonl over .json)
457fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
458    // First try exact match (.jsonl preferred)
459    let exact_jsonl = chat_sessions_dir.join(format!("{}.jsonl", session_id));
460    if exact_jsonl.exists() {
461        return Ok(exact_jsonl);
462    }
463    let exact_json = chat_sessions_dir.join(format!("{}.json", session_id));
464    if exact_json.exists() {
465        return Ok(exact_json);
466    }
467
468    // Try partial match (prefix), preferring .jsonl
469    let mut best_match: Option<PathBuf> = None;
470    for entry in std::fs::read_dir(chat_sessions_dir)? {
471        let entry = entry?;
472        let path = entry.path();
473
474        if path
475            .extension()
476            .map(is_session_file_extension)
477            .unwrap_or(false)
478        {
479            let filename = path
480                .file_stem()
481                .map(|s| s.to_string_lossy().to_string())
482                .unwrap_or_default();
483
484            if filename.starts_with(session_id) {
485                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
486                if best_match.is_none() || is_jsonl {
487                    best_match = Some(path.clone());
488                    if is_jsonl {
489                        return Ok(path);
490                    }
491                }
492                continue;
493            }
494
495            // Also check session_id inside the file
496            if let Ok(session) = parse_session_file(&path) {
497                if let Some(ref sid) = session.session_id {
498                    if sid.starts_with(session_id) || sid == session_id {
499                        let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
500                        if best_match.is_none() || is_jsonl {
501                            best_match = Some(path.clone());
502                        }
503                    }
504                }
505            }
506        }
507    }
508
509    best_match.ok_or_else(|| CsmError::SessionNotFound(session_id.to_string()).into())
510}
511
512/// Find sessions by title (case-insensitive partial match)
513fn find_sessions_by_titles(
514    chat_sessions_dir: &PathBuf,
515    titles: &[String],
516) -> Result<Vec<(ChatSession, PathBuf)>> {
517    let mut matches = Vec::new();
518    let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
519
520    // Collect files, preferring .jsonl over .json for the same session ID
521    let mut session_files: std::collections::HashMap<String, PathBuf> =
522        std::collections::HashMap::new();
523    for entry in std::fs::read_dir(chat_sessions_dir)? {
524        let entry = entry?;
525        let path = entry.path();
526        if path
527            .extension()
528            .map(is_session_file_extension)
529            .unwrap_or(false)
530        {
531            if let Some(stem) = path.file_stem() {
532                let stem_str = stem.to_string_lossy().to_string();
533                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
534                if !session_files.contains_key(&stem_str) || is_jsonl {
535                    session_files.insert(stem_str, path);
536                }
537            }
538        }
539    }
540
541    for (_, path) in &session_files {
542        if let Ok(session) = parse_session_file(path) {
543            let session_title = session.title().to_lowercase();
544
545            for pattern in &title_patterns {
546                if session_title.contains(pattern) {
547                    matches.push((session, path.clone()));
548                    break;
549                }
550            }
551        }
552    }
553
554    if matches.is_empty() {
555        println!(
556            "{} No sessions found matching the specified titles",
557            "[!]".yellow()
558        );
559    }
560
561    Ok(matches)
562}
563
564/// Recursively walk directories and register orphaned sessions for all workspaces found
565pub fn register_recursive(
566    root_path: Option<&str>,
567    max_depth: Option<usize>,
568    force: bool,
569    dry_run: bool,
570    exclude_patterns: &[String],
571) -> Result<()> {
572    let root = resolve_path(root_path);
573
574    println!(
575        "{} Scanning for workspaces recursively from: {}",
576        "[CSM]".cyan().bold(),
577        root.display()
578    );
579
580    if dry_run {
581        println!("{} Dry run mode - no changes will be made", "[!]".yellow());
582    }
583
584    // Check if VS Code is running
585    if !force && !dry_run && is_vscode_running() {
586        println!(
587            "{} VS Code is running. Use {} to register anyway.",
588            "[!]".yellow(),
589            "--force".cyan()
590        );
591        println!("   Note: VS Code uses WAL mode so this is generally safe.");
592        return Err(CsmError::VSCodeRunning.into());
593    }
594
595    // Get all VS Code workspaces
596    let workspaces = discover_workspaces()?;
597    println!(
598        "   Found {} VS Code workspaces to check",
599        workspaces.len().to_string().cyan()
600    );
601
602    // Build a map of normalized project paths to workspace info
603    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
604        std::collections::HashMap::new();
605    for ws in &workspaces {
606        if let Some(ref project_path) = ws.project_path {
607            let normalized = normalize_path(project_path);
608            workspace_map.entry(normalized).or_default().push(ws);
609        }
610    }
611
612    // Compile exclude patterns
613    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
614        .iter()
615        .filter_map(|p| glob::Pattern::new(p).ok())
616        .collect();
617
618    // Default exclusions for common non-project directories
619    let default_excludes = [
620        "node_modules",
621        ".git",
622        "target",
623        "build",
624        "dist",
625        ".venv",
626        "venv",
627        "__pycache__",
628        ".cache",
629        "vendor",
630        ".cargo",
631    ];
632
633    let mut total_dirs_scanned = 0;
634    let mut workspaces_found = 0;
635    let mut total_sessions_registered = 0;
636    let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
637
638    // Walk the directory tree
639    walk_directory(
640        &root,
641        &root,
642        0,
643        max_depth,
644        &workspace_map,
645        &exclude_matchers,
646        &default_excludes,
647        force,
648        dry_run,
649        &mut total_dirs_scanned,
650        &mut workspaces_found,
651        &mut total_sessions_registered,
652        &mut workspaces_with_orphans,
653    )?;
654
655    // Print summary
656    println!("\n{}", "═".repeat(60).cyan());
657    println!("{} Recursive scan complete", "[OK]".green().bold());
658    println!("{}", "═".repeat(60).cyan());
659    println!(
660        "   Directories scanned:    {}",
661        total_dirs_scanned.to_string().cyan()
662    );
663    println!(
664        "   Workspaces found:       {}",
665        workspaces_found.to_string().cyan()
666    );
667    println!(
668        "   Sessions registered:    {}",
669        total_sessions_registered.to_string().green()
670    );
671
672    if !workspaces_with_orphans.is_empty() {
673        println!("\n   {} Workspaces with orphaned sessions:", "[+]".green());
674        for (path, orphaned, registered) in &workspaces_with_orphans {
675            let reg_str = if dry_run {
676                format!("would register {}", registered)
677            } else {
678                format!("registered {}", registered)
679            };
680            println!(
681                "      {} ({} orphaned, {})",
682                path.cyan(),
683                orphaned.to_string().yellow(),
684                reg_str.green()
685            );
686        }
687    }
688
689    if total_sessions_registered > 0 && !dry_run {
690        println!(
691            "\n{} VS Code caches the session index in memory.",
692            "[!]".yellow()
693        );
694        println!("   To see the new sessions, do one of the following:");
695        println!(
696            "   * Run: {} (if CSM extension is installed)",
697            "code --command csm.reloadAndShowChats".cyan()
698        );
699        println!(
700            "   * Or press {} in VS Code and run {}",
701            "Ctrl+Shift+P".cyan(),
702            "Developer: Reload Window".cyan()
703        );
704        println!("   * Or restart VS Code");
705    }
706
707    Ok(())
708}
709
710/// Recursively walk a directory and process workspaces
711#[allow(clippy::too_many_arguments)]
712fn walk_directory(
713    current_dir: &Path,
714    root: &Path,
715    current_depth: usize,
716    max_depth: Option<usize>,
717    workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
718    exclude_matchers: &[glob::Pattern],
719    default_excludes: &[&str],
720    force: bool,
721    dry_run: bool,
722    total_dirs_scanned: &mut usize,
723    workspaces_found: &mut usize,
724    total_sessions_registered: &mut usize,
725    workspaces_with_orphans: &mut Vec<(String, usize, usize)>,
726) -> Result<()> {
727    // Check depth limit
728    if let Some(max) = max_depth {
729        if current_depth > max {
730            return Ok(());
731        }
732    }
733
734    *total_dirs_scanned += 1;
735
736    // Get directory name for exclusion checking
737    let dir_name = current_dir
738        .file_name()
739        .map(|n| n.to_string_lossy().to_string())
740        .unwrap_or_default();
741
742    // Skip default excluded directories
743    if default_excludes.contains(&dir_name.as_str()) {
744        return Ok(());
745    }
746
747    // Skip if matches user exclusion patterns
748    let relative_path = current_dir
749        .strip_prefix(root)
750        .unwrap_or(current_dir)
751        .to_string_lossy();
752    for pattern in exclude_matchers {
753        if pattern.matches(&relative_path) || pattern.matches(&dir_name) {
754            return Ok(());
755        }
756    }
757
758    // Check if this directory is a VS Code workspace
759    let normalized_path = normalize_path(&current_dir.to_string_lossy());
760    if let Some(workspace_entries) = workspace_map.get(&normalized_path) {
761        *workspaces_found += 1;
762
763        for ws in workspace_entries {
764            // Check for orphaned sessions in this workspace
765            if ws.has_chat_sessions {
766                let chat_sessions_dir = &ws.chat_sessions_path;
767
768                // Count orphaned sessions
769                match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
770                    Ok((on_disk, in_index, orphaned_count)) => {
771                        if orphaned_count > 0 {
772                            let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
773
774                            if dry_run {
775                                println!(
776                                    "   {} {} - {} sessions on disk, {} in index, {} orphaned",
777                                    "[DRY]".yellow(),
778                                    display_path.cyan(),
779                                    on_disk.to_string().white(),
780                                    in_index.to_string().white(),
781                                    orphaned_count.to_string().yellow()
782                                );
783                                workspaces_with_orphans.push((
784                                    display_path.to_string(),
785                                    orphaned_count,
786                                    orphaned_count,
787                                ));
788                            } else {
789                                // Register the sessions
790                                match register_all_sessions_from_directory(
791                                    &ws.hash,
792                                    chat_sessions_dir,
793                                    force,
794                                ) {
795                                    Ok(registered) => {
796                                        *total_sessions_registered += registered;
797                                        println!(
798                                            "   {} {} - registered {} sessions",
799                                            "[+]".green(),
800                                            display_path.cyan(),
801                                            registered.to_string().green()
802                                        );
803                                        workspaces_with_orphans.push((
804                                            display_path.to_string(),
805                                            orphaned_count,
806                                            registered,
807                                        ));
808                                    }
809                                    Err(e) => {
810                                        println!(
811                                            "   {} {} - error: {}",
812                                            "[!]".red(),
813                                            display_path.cyan(),
814                                            e
815                                        );
816                                    }
817                                }
818                            }
819                        }
820                    }
821                    Err(e) => {
822                        let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
823                        println!(
824                            "   {} {} - error checking: {}",
825                            "[!]".yellow(),
826                            display_path,
827                            e
828                        );
829                    }
830                }
831            }
832        }
833    }
834
835    // Recurse into subdirectories
836    match std::fs::read_dir(current_dir) {
837        Ok(entries) => {
838            for entry in entries.flatten() {
839                let path = entry.path();
840                if path.is_dir() {
841                    // Skip hidden directories
842                    let name = path
843                        .file_name()
844                        .map(|n| n.to_string_lossy().to_string())
845                        .unwrap_or_default();
846                    if name.starts_with('.') {
847                        continue;
848                    }
849
850                    walk_directory(
851                        &path,
852                        root,
853                        current_depth + 1,
854                        max_depth,
855                        workspace_map,
856                        exclude_matchers,
857                        default_excludes,
858                        force,
859                        dry_run,
860                        total_dirs_scanned,
861                        workspaces_found,
862                        total_sessions_registered,
863                        workspaces_with_orphans,
864                    )?;
865                }
866            }
867        }
868        Err(e) => {
869            // Permission denied or other errors - skip silently
870            if e.kind() != std::io::ErrorKind::PermissionDenied {
871                eprintln!(
872                    "   {} Could not read {}: {}",
873                    "[!]".yellow(),
874                    current_dir.display(),
875                    e
876                );
877            }
878        }
879    }
880
881    Ok(())
882}
883
884/// Count orphaned sessions in a workspace (on disk but not in index)
885fn count_orphaned_sessions(
886    workspace_id: &str,
887    chat_sessions_dir: &Path,
888) -> Result<(usize, usize, usize)> {
889    // Get sessions in index
890    let db_path = get_workspace_storage_db(workspace_id)?;
891    let indexed_sessions = read_chat_session_index(&db_path)?;
892    let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
893
894    // Count unique sessions on disk (preferring .jsonl over .json)
895    let mut disk_sessions: HashSet<String> = HashSet::new();
896
897    for entry in std::fs::read_dir(chat_sessions_dir)? {
898        let entry = entry?;
899        let path = entry.path();
900
901        if path
902            .extension()
903            .map(is_session_file_extension)
904            .unwrap_or(false)
905        {
906            if let Some(stem) = path.file_stem() {
907                disk_sessions.insert(stem.to_string_lossy().to_string());
908            }
909        }
910    }
911
912    let on_disk = disk_sessions.len();
913    let orphaned = disk_sessions
914        .iter()
915        .filter(|id| !indexed_ids.contains(*id))
916        .count();
917
918    Ok((on_disk, indexed_ids.len(), orphaned))
919}
920
921/// Repair sessions: compact JSONL files and rebuild the index with correct metadata
922pub fn register_repair(
923    project_path: Option<&str>,
924    all: bool,
925    recursive: bool,
926    max_depth: Option<usize>,
927    exclude_patterns: &[String],
928    dry_run: bool,
929    force: bool,
930    close_vscode: bool,
931    reopen: bool,
932) -> Result<()> {
933    if all {
934        return register_repair_all(force, close_vscode, reopen);
935    }
936
937    if recursive {
938        return register_repair_recursive(
939            project_path,
940            max_depth,
941            exclude_patterns,
942            dry_run,
943            force,
944            close_vscode,
945            reopen,
946        );
947    }
948
949    let path = resolve_path(project_path);
950    let should_close = close_vscode || reopen;
951
952    println!(
953        "{} Repairing sessions for: {}",
954        "[CSM]".cyan().bold(),
955        path.display()
956    );
957
958    // Find the workspace
959    let path_str = path.to_string_lossy().to_string();
960    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
961        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
962
963    let chat_sessions_dir = ws_path.join("chatSessions");
964
965    if !chat_sessions_dir.exists() {
966        println!(
967            "{} No chatSessions directory found at: {}",
968            "[!]".yellow(),
969            chat_sessions_dir.display()
970        );
971        return Ok(());
972    }
973
974    // Handle VS Code lifecycle
975    let vscode_was_running = is_vscode_running();
976    if vscode_was_running {
977        if should_close {
978            if !confirm_close_vscode(force) {
979                println!("{} Aborted.", "[!]".yellow());
980                return Ok(());
981            }
982            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
983            close_vscode_and_wait(30)?;
984            println!("   {} VS Code closed.", "[OK]".green());
985        } else if !force {
986            println!(
987                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
988                "[!]".yellow()
989            );
990            println!(
991                "   Use {} to close VS Code first, or {} to force.",
992                "--reopen".cyan(),
993                "--force".cyan()
994            );
995            return Err(CsmError::VSCodeRunning.into());
996        }
997    }
998
999    // Run the repair
1000    println!("   {} Pass 1: Compacting JSONL files...", "[*]".cyan());
1001    let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1002
1003    println!("   {} Pass 2: Index rebuilt.", "[*]".cyan());
1004    println!(
1005        "\n{} Repair complete: {} files compacted, {} index entries synced",
1006        "[OK]".green().bold(),
1007        compacted.to_string().cyan(),
1008        index_fixed.to_string().cyan()
1009    );
1010
1011    // Delete stale .json files when a .jsonl exists for the same session
1012    let mut deleted_json = 0;
1013    if chat_sessions_dir.exists() {
1014        let mut jsonl_sessions: HashSet<String> = HashSet::new();
1015        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1016            let entry = entry?;
1017            let p = entry.path();
1018            if p.extension().is_some_and(|e| e == "jsonl") {
1019                if let Some(stem) = p.file_stem() {
1020                    jsonl_sessions.insert(stem.to_string_lossy().to_string());
1021                }
1022            }
1023        }
1024        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1025            let entry = entry?;
1026            let p = entry.path();
1027            if p.extension().is_some_and(|e| e == "json") {
1028                if let Some(stem) = p.file_stem() {
1029                    if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1030                        // Rename to .json.bak to preserve as backup
1031                        let bak = p.with_extension("json.bak");
1032                        std::fs::rename(&p, &bak)?;
1033                        println!(
1034                            "   {} Backed up stale .json: {} → {}",
1035                            "[*]".yellow(),
1036                            p.file_name().unwrap_or_default().to_string_lossy(),
1037                            bak.file_name().unwrap_or_default().to_string_lossy()
1038                        );
1039                        deleted_json += 1;
1040                    }
1041                }
1042            }
1043        }
1044        if deleted_json > 0 {
1045            // Re-sync index after removing .json files
1046            repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1047            println!(
1048                "   {} Removed {} stale .json duplicates (backed up as .json.bak)",
1049                "[OK]".green(),
1050                deleted_json
1051            );
1052        }
1053    }
1054
1055    // Reopen VS Code if requested
1056    if reopen && vscode_was_running {
1057        println!("   {} Reopening VS Code...", "[*]".yellow());
1058        reopen_vscode(Some(&path_str))?;
1059        println!(
1060            "   {} VS Code launched. Sessions should now load correctly.",
1061            "[OK]".green()
1062        );
1063    } else if should_close && vscode_was_running {
1064        println!(
1065            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1066            "[!]".yellow()
1067        );
1068        println!("   Run: {}", format!("code {}", path.display()).cyan());
1069    }
1070
1071    Ok(())
1072}
1073
1074/// Recursively scan a directory tree for workspaces and repair all discovered sessions
1075fn register_repair_recursive(
1076    root_path: Option<&str>,
1077    max_depth: Option<usize>,
1078    exclude_patterns: &[String],
1079    dry_run: bool,
1080    force: bool,
1081    close_vscode: bool,
1082    reopen: bool,
1083) -> Result<()> {
1084    let root = resolve_path(root_path);
1085    let should_close = close_vscode || reopen;
1086
1087    println!(
1088        "{} Recursively scanning for workspaces to repair from: {}",
1089        "[CSM]".cyan().bold(),
1090        root.display()
1091    );
1092
1093    if dry_run {
1094        println!("{} Dry run mode — no changes will be made", "[!]".yellow());
1095    }
1096
1097    // Handle VS Code lifecycle
1098    let vscode_was_running = is_vscode_running();
1099    if vscode_was_running && !dry_run {
1100        if should_close {
1101            if !confirm_close_vscode(force) {
1102                println!("{} Aborted.", "[!]".yellow());
1103                return Ok(());
1104            }
1105            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1106            close_vscode_and_wait(30)?;
1107            println!("   {} VS Code closed.\n", "[OK]".green());
1108        } else if !force {
1109            println!(
1110                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1111                "[!]".yellow()
1112            );
1113            println!(
1114                "   Use {} to close VS Code first, or {} to force.",
1115                "--reopen".cyan(),
1116                "--force".cyan()
1117            );
1118            return Err(CsmError::VSCodeRunning.into());
1119        }
1120    }
1121
1122    // Get all VS Code workspaces
1123    let workspaces = discover_workspaces()?;
1124    println!(
1125        "   Found {} VS Code workspaces to check",
1126        workspaces.len().to_string().cyan()
1127    );
1128
1129    // Build a map of normalized project paths to workspace info
1130    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
1131        std::collections::HashMap::new();
1132    for ws in &workspaces {
1133        if let Some(ref project_path) = ws.project_path {
1134            let normalized = normalize_path(project_path);
1135            workspace_map.entry(normalized).or_default().push(ws);
1136        }
1137    }
1138
1139    // Compile exclude patterns
1140    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
1141        .iter()
1142        .filter_map(|p| glob::Pattern::new(p).ok())
1143        .collect();
1144
1145    let default_excludes = [
1146        "node_modules",
1147        ".git",
1148        "target",
1149        "build",
1150        "dist",
1151        ".venv",
1152        "venv",
1153        "__pycache__",
1154        ".cache",
1155        "vendor",
1156        ".cargo",
1157    ];
1158
1159    let mut total_dirs_scanned = 0usize;
1160    let mut workspaces_found = 0usize;
1161    let mut total_compacted = 0usize;
1162    let mut total_synced = 0usize;
1163    let mut total_issues_found = 0usize;
1164    let mut total_issues_fixed = 0usize;
1165    let mut repair_results: Vec<(String, usize, bool, String)> = Vec::new(); // (path, issues, success, detail)
1166
1167    // Walk the directory tree looking for known workspaces
1168    fn walk_for_repair(
1169        dir: &Path,
1170        root: &Path,
1171        current_depth: usize,
1172        max_depth: Option<usize>,
1173        workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
1174        exclude_matchers: &[glob::Pattern],
1175        default_excludes: &[&str],
1176        dry_run: bool,
1177        force: bool,
1178        total_dirs_scanned: &mut usize,
1179        workspaces_found: &mut usize,
1180        total_compacted: &mut usize,
1181        total_synced: &mut usize,
1182        total_issues_found: &mut usize,
1183        total_issues_fixed: &mut usize,
1184        repair_results: &mut Vec<(String, usize, bool, String)>,
1185    ) -> Result<()> {
1186        if let Some(max) = max_depth {
1187            if current_depth > max {
1188                return Ok(());
1189            }
1190        }
1191
1192        *total_dirs_scanned += 1;
1193
1194        // Check if this directory is a known workspace
1195        let normalized = normalize_path(&dir.to_string_lossy());
1196        if let Some(ws_list) = workspace_map.get(&normalized) {
1197            for ws in ws_list {
1198                if ws.has_chat_sessions && ws.chat_session_count > 0 {
1199                    *workspaces_found += 1;
1200
1201                    let display_name = ws
1202                        .project_path
1203                        .as_deref()
1204                        .unwrap_or(&ws.hash);
1205
1206                    // Diagnose first
1207                    let chat_dir = ws.workspace_path.join("chatSessions");
1208                    match crate::storage::diagnose_workspace_sessions(&ws.hash, &chat_dir) {
1209                        Ok(diag) => {
1210                            let issue_count = diag.issues.len();
1211                            *total_issues_found += issue_count;
1212
1213                            if issue_count == 0 {
1214                                println!(
1215                                    "   {} {} — {} sessions, healthy",
1216                                    "[OK]".green(),
1217                                    display_name.cyan(),
1218                                    ws.chat_session_count
1219                                );
1220                                repair_results.push((
1221                                    display_name.to_string(),
1222                                    0,
1223                                    true,
1224                                    "healthy".to_string(),
1225                                ));
1226                            } else {
1227                                let issue_kinds: Vec<String> = {
1228                                    let mut kinds: Vec<String> = Vec::new();
1229                                    for issue in &diag.issues {
1230                                        let s = format!("{}", issue.kind);
1231                                        if !kinds.contains(&s) {
1232                                            kinds.push(s);
1233                                        }
1234                                    }
1235                                    kinds
1236                                };
1237
1238                                println!(
1239                                    "   {} {} — {} sessions, {} issue(s): {}",
1240                                    "[!]".yellow(),
1241                                    display_name.cyan(),
1242                                    ws.chat_session_count,
1243                                    issue_count,
1244                                    issue_kinds.join(", ")
1245                                );
1246
1247                                if !dry_run {
1248                                    match repair_workspace_sessions(
1249                                        &ws.hash, &chat_dir, force || true,
1250                                    ) {
1251                                        Ok((compacted, synced)) => {
1252                                            *total_compacted += compacted;
1253                                            *total_synced += synced;
1254                                            *total_issues_fixed += issue_count;
1255
1256                                            // Also handle stale .json cleanup
1257                                            let mut deleted_json = 0;
1258                                            let mut jsonl_sessions: HashSet<String> =
1259                                                HashSet::new();
1260                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1261                                                for entry in entries.flatten() {
1262                                                    let p = entry.path();
1263                                                    if p.extension()
1264                                                        .is_some_and(|e| e == "jsonl")
1265                                                    {
1266                                                        if let Some(stem) = p.file_stem() {
1267                                                            jsonl_sessions.insert(
1268                                                                stem.to_string_lossy().to_string(),
1269                                                            );
1270                                                        }
1271                                                    }
1272                                                }
1273                                            }
1274                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1275                                                for entry in entries.flatten() {
1276                                                    let p = entry.path();
1277                                                    if p.extension().is_some_and(|e| e == "json")
1278                                                    {
1279                                                        if let Some(stem) = p.file_stem() {
1280                                                            if jsonl_sessions.contains(
1281                                                                &stem.to_string_lossy().to_string(),
1282                                                            ) {
1283                                                                let bak =
1284                                                                    p.with_extension("json.bak");
1285                                                                let _ = std::fs::rename(&p, &bak);
1286                                                                deleted_json += 1;
1287                                                            }
1288                                                        }
1289                                                    }
1290                                                }
1291                                            }
1292                                            if deleted_json > 0 {
1293                                                let _ = repair_workspace_sessions(
1294                                                    &ws.hash,
1295                                                    &chat_dir,
1296                                                    true,
1297                                                );
1298                                            }
1299
1300                                            let detail = format!(
1301                                                "{} compacted, {} synced{}",
1302                                                compacted,
1303                                                synced,
1304                                                if deleted_json > 0 {
1305                                                    format!(
1306                                                        ", {} stale .json backed up",
1307                                                        deleted_json
1308                                                    )
1309                                                } else {
1310                                                    String::new()
1311                                                }
1312                                            );
1313                                            println!(
1314                                                "      {} Fixed: {}",
1315                                                "[OK]".green(),
1316                                                detail
1317                                            );
1318                                            repair_results.push((
1319                                                display_name.to_string(),
1320                                                issue_count,
1321                                                true,
1322                                                detail,
1323                                            ));
1324                                        }
1325                                        Err(e) => {
1326                                            println!(
1327                                                "      {} Failed: {}",
1328                                                "[ERR]".red(),
1329                                                e
1330                                            );
1331                                            repair_results.push((
1332                                                display_name.to_string(),
1333                                                issue_count,
1334                                                false,
1335                                                e.to_string(),
1336                                            ));
1337                                        }
1338                                    }
1339                                } else {
1340                                    // Dry run: just list the issues
1341                                    for issue in &diag.issues {
1342                                        println!(
1343                                            "      {} {} — {}",
1344                                            "→".bright_black(),
1345                                            issue.session_id[..8.min(issue.session_id.len())]
1346                                                .to_string(),
1347                                            issue.kind
1348                                        );
1349                                    }
1350                                    repair_results.push((
1351                                        display_name.to_string(),
1352                                        issue_count,
1353                                        true,
1354                                        "dry run".to_string(),
1355                                    ));
1356                                }
1357                            }
1358                        }
1359                        Err(e) => {
1360                            println!(
1361                                "   {} {} — scan failed: {}",
1362                                "[ERR]".red(),
1363                                display_name,
1364                                e
1365                            );
1366                        }
1367                    }
1368                }
1369            }
1370        }
1371
1372        // Recurse into subdirectories
1373        let entries = match std::fs::read_dir(dir) {
1374            Ok(e) => e,
1375            Err(_) => return Ok(()),
1376        };
1377
1378        for entry in entries {
1379            let entry = match entry {
1380                Ok(e) => e,
1381                Err(_) => continue,
1382            };
1383            let path = entry.path();
1384            if !path.is_dir() {
1385                continue;
1386            }
1387
1388            let dir_name = entry.file_name().to_string_lossy().to_string();
1389
1390            // Skip hidden directories
1391            if dir_name.starts_with('.') {
1392                continue;
1393            }
1394
1395            // Skip default excludes
1396            if default_excludes.iter().any(|e| dir_name == *e) {
1397                continue;
1398            }
1399
1400            // Skip user-specified excludes
1401            if exclude_matchers
1402                .iter()
1403                .any(|p| p.matches(&dir_name))
1404            {
1405                continue;
1406            }
1407
1408            walk_for_repair(
1409                &path,
1410                root,
1411                current_depth + 1,
1412                max_depth,
1413                workspace_map,
1414                exclude_matchers,
1415                default_excludes,
1416                dry_run,
1417                force,
1418                total_dirs_scanned,
1419                workspaces_found,
1420                total_compacted,
1421                total_synced,
1422                total_issues_found,
1423                total_issues_fixed,
1424                repair_results,
1425            )?;
1426        }
1427
1428        Ok(())
1429    }
1430
1431    walk_for_repair(
1432        &root,
1433        &root,
1434        0,
1435        max_depth,
1436        &workspace_map,
1437        &exclude_matchers,
1438        &default_excludes,
1439        dry_run,
1440        force,
1441        &mut total_dirs_scanned,
1442        &mut workspaces_found,
1443        &mut total_compacted,
1444        &mut total_synced,
1445        &mut total_issues_found,
1446        &mut total_issues_fixed,
1447        &mut repair_results,
1448    )?;
1449
1450    // Print summary
1451    println!("\n{}", "═".repeat(60).cyan());
1452    println!("{} Recursive repair scan complete", "[OK]".green().bold());
1453    println!("{}", "═".repeat(60).cyan());
1454    println!(
1455        "   Directories scanned:    {}",
1456        total_dirs_scanned.to_string().cyan()
1457    );
1458    println!(
1459        "   Workspaces found:       {}",
1460        workspaces_found.to_string().cyan()
1461    );
1462    println!(
1463        "   Issues detected:        {}",
1464        if total_issues_found > 0 {
1465            total_issues_found.to_string().yellow()
1466        } else {
1467            total_issues_found.to_string().green()
1468        }
1469    );
1470    if !dry_run {
1471        println!(
1472            "   Issues fixed:           {}",
1473            total_issues_fixed.to_string().green()
1474        );
1475        println!(
1476            "   Files compacted:        {}",
1477            total_compacted.to_string().cyan()
1478        );
1479        println!(
1480            "   Index entries synced:   {}",
1481            total_synced.to_string().cyan()
1482        );
1483    }
1484
1485    let failed_count = repair_results.iter().filter(|(_, _, ok, _)| !ok).count();
1486    if failed_count > 0 {
1487        println!(
1488            "\n   {} {} workspace(s) had repair errors",
1489            "[!]".yellow(),
1490            failed_count.to_string().red()
1491        );
1492    }
1493
1494    // Reopen VS Code if requested
1495    if reopen && vscode_was_running {
1496        println!("   {} Reopening VS Code...", "[*]".yellow());
1497        reopen_vscode(None)?;
1498        println!(
1499            "   {} VS Code launched. Sessions should now load correctly.",
1500            "[OK]".green()
1501        );
1502    } else if should_close && vscode_was_running {
1503        println!(
1504            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1505            "[!]".yellow()
1506        );
1507    }
1508
1509    Ok(())
1510}
1511
1512/// Repair all workspaces that have chat sessions
1513fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1514    let should_close = close_vscode || reopen;
1515
1516    println!(
1517        "{} Repairing all workspaces with chat sessions...\n",
1518        "[CSM]".cyan().bold(),
1519    );
1520
1521    // Handle VS Code lifecycle once for all workspaces
1522    let vscode_was_running = is_vscode_running();
1523    if vscode_was_running {
1524        if should_close {
1525            if !confirm_close_vscode(force) {
1526                println!("{} Aborted.", "[!]".yellow());
1527                return Ok(());
1528            }
1529            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1530            close_vscode_and_wait(30)?;
1531            println!("   {} VS Code closed.\n", "[OK]".green());
1532        } else if !force {
1533            println!(
1534                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1535                "[!]".yellow()
1536            );
1537            println!(
1538                "   Use {} to close VS Code first, or {} to force.",
1539                "--reopen".cyan(),
1540                "--force".cyan()
1541            );
1542            return Err(CsmError::VSCodeRunning.into());
1543        }
1544    }
1545
1546    let workspaces = discover_workspaces()?;
1547    let ws_with_sessions: Vec<_> = workspaces
1548        .iter()
1549        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1550        .collect();
1551
1552    if ws_with_sessions.is_empty() {
1553        println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1554        return Ok(());
1555    }
1556
1557    println!(
1558        "   Found {} workspaces with chat sessions\n",
1559        ws_with_sessions.len().to_string().cyan()
1560    );
1561
1562    let mut total_compacted = 0usize;
1563    let mut total_synced = 0usize;
1564    let mut succeeded = 0usize;
1565    let mut failed = 0usize;
1566
1567    for (i, ws) in ws_with_sessions.iter().enumerate() {
1568        let display_name = ws
1569            .project_path
1570            .as_deref()
1571            .unwrap_or(&ws.hash);
1572        println!(
1573            "[{}/{}] {} {}",
1574            i + 1,
1575            ws_with_sessions.len(),
1576            "===".dimmed(),
1577            display_name.cyan()
1578        );
1579
1580        let chat_sessions_dir = ws.workspace_path.join("chatSessions");
1581        if !chat_sessions_dir.exists() {
1582            println!("   {} No chatSessions directory, skipping.\n", "[!]".yellow());
1583            continue;
1584        }
1585
1586        match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
1587            Ok((compacted, index_fixed)) => {
1588                // Delete stale .json files when a .jsonl exists for the same session
1589                let mut deleted_json = 0;
1590                let mut jsonl_sessions: HashSet<String> = HashSet::new();
1591                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1592                    let entry = entry?;
1593                    let p = entry.path();
1594                    if p.extension().is_some_and(|e| e == "jsonl") {
1595                        if let Some(stem) = p.file_stem() {
1596                            jsonl_sessions.insert(stem.to_string_lossy().to_string());
1597                        }
1598                    }
1599                }
1600                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1601                    let entry = entry?;
1602                    let p = entry.path();
1603                    if p.extension().is_some_and(|e| e == "json") {
1604                        if let Some(stem) = p.file_stem() {
1605                            if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1606                                let bak = p.with_extension("json.bak");
1607                                std::fs::rename(&p, &bak)?;
1608                                deleted_json += 1;
1609                            }
1610                        }
1611                    }
1612                }
1613                if deleted_json > 0 {
1614                    repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
1615                }
1616
1617                total_compacted += compacted;
1618                total_synced += index_fixed;
1619                succeeded += 1;
1620                println!(
1621                    "   {} {} compacted, {} synced{}\n",
1622                    "[OK]".green(),
1623                    compacted,
1624                    index_fixed,
1625                    if deleted_json > 0 {
1626                        format!(", {} stale .json backed up", deleted_json)
1627                    } else {
1628                        String::new()
1629                    }
1630                );
1631            }
1632            Err(e) => {
1633                failed += 1;
1634                println!("   {} {}\n", "[ERR]".red(), e);
1635            }
1636        }
1637    }
1638
1639    println!(
1640        "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
1641        "[OK]".green().bold(),
1642        succeeded.to_string().green(),
1643        ws_with_sessions.len(),
1644        total_compacted.to_string().cyan(),
1645        total_synced.to_string().cyan()
1646    );
1647    if failed > 0 {
1648        println!(
1649            "   {} {} workspace(s) had errors",
1650            "[!]".yellow(),
1651            failed.to_string().red()
1652        );
1653    }
1654
1655    // Reopen VS Code if requested
1656    if reopen && vscode_was_running {
1657        println!("   {} Reopening VS Code...", "[*]".yellow());
1658        reopen_vscode(None)?;
1659        println!(
1660            "   {} VS Code launched. Sessions should now load correctly.",
1661            "[OK]".green()
1662        );
1663    } else if should_close && vscode_was_running {
1664        println!(
1665            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1666            "[!]".yellow()
1667        );
1668    }
1669
1670    Ok(())
1671}