Skip to content

[BUG] sessions-index.json stops being updated after v2.1.31 — new sessions invisible to /resume #23614

Closed

Description

@Sajakhtar

Bug Description

After updating to Claude Code v2.1.31, new sessions are no longer being registered in sessions-index.json. Session .jsonl files are still being written to disk, but the index that /resume reads from stopped being updated. This makes all sessions created after the update invisible to /resume.

Reproduction Steps

  1. Update to Claude Code v2.1.31+ (confirmed on 2.1.31 through 2.1.34)
  2. Start a new session, do some work, exit
  3. Run claude and use /resume to find the session
  4. Session does not appear in the list

Diagnosis

# Count .jsonl session files vs indexed entries
PROJ_DIR=~/.claude/projects/<your-project-dir>
echo "JSONL files on disk: $(ls $PROJ_DIR/*.jsonl 2>/dev/null | wc -l)"
echo "Entries in index:    $(python3 -c "import json; print(len(json.load(open('$PROJ_DIR/sessions-index.json'))['entries']))")"
echo "Index last modified: $(stat -f '%Sm' -t '%Y-%m-%d %H:%M' $PROJ_DIR/sessions-index.json)"

In my case:

  • 98 .jsonl files on disk
  • 37 entries in sessions-index.json
  • 6 real sessions created after Feb 4 are not indexed (the rest of the gap is agent sub-sessions, file-history snapshots, etc. that were never meant to be indexed)
  • Index was last modified 2026-02-04 09:09 — the day v2.1.31 was installed
  • The newest indexed session is from 2026-02-03

Workaround

If you know the session ID (from the .jsonl filename), you can still resume directly:

claude --resume <session-id>

But browsing/searching via /resume won't show any session created after the index stopped updating.

Environment

  • Claude Code versions: 2.1.31, 2.1.32, 2.1.33, 2.1.34 (all affected)
  • Last working version: Unknown, but index has entries up to 2026-02-03 (likely 2.1.30 or earlier)
  • Platform: macOS Darwin 25.2.0
  • Node: v22.22.0

Related Issues

Activity

  1. github-actions commented on Feb 6, 2026

    @github-actions

    Found 3 possible duplicate issues:

    1. [BUG] sessions-index.json stops updating - new sessions not appearing in /resume picker (Linux/WSL2) #23421
    2. sessions-index.json not updated - claude -c fails to find sessions #22462
    3. Sessions not added to sessions-index.json, invisible to resume #22205

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  2. Sajakhtar commented on Feb 6, 2026

    @Sajakhtar
    Author

    Update: sessions-index.json is not used by /resume at all in v2.1.34

    After further investigation, the issue is different (and bigger) than initially reported.

    What we tried

    1. Deleted sessions-index.json to force a rebuild — Claude Code regenerated it but only with ~0 entries
    2. Rebuilt the index manually by merging all 119 sessions from the main project dir + 27 worktree project dirs — confirmed the file had 119 entries and was not overwritten by Claude Code
    3. /resume still only showed ~5 recent sessions — proving it doesn't read from sessions-index.json at all

    Actual behavior in v2.1.34

    The /resume session picker appears to scan .jsonl files by recency and only displays the most recent ~5 sessions. It does not read from sessions-index.json.

    This means:

    • sessions-index.json appears to be vestigial — written to but never read from by the picker
    • The new picker mechanism only shows a small handful of recent sessions
    • All older sessions are invisible unless you know the exact session ID and use claude --resume <id>
    • Pressing Ctrl+A ("show all projects") in the picker does not reveal additional sessions

    Previously (before ~v2.1.31)

    The /resume picker showed all sessions including those from git worktrees. Users could browse and search through their full session history.

    Impact

    For users with many sessions across worktrees (in our case 119+ sessions across 28 project directories), this is a significant regression in discoverability. The only workaround is manually tracking session IDs.

  3. bosmadev commented on Feb 6, 2026

    @bosmadev

    Workaround: auto-repair via SessionStart hook

    Same issue confirmed on Windows 11 (CC 2.1.34) — 25 of 95 sessions indexed. See #23614 for the full root cause analysis.

    Built a repair script + SessionStart hook that auto-fixes this on every CC launch:

    Repair script (repair-sessions-index.py)

    Click to expand full script (~250 lines)
    #!/usr/bin/env python3
    """
    Session Index Repair Script
    
    Repairs Claude Code sessions-index.json by:
    - Finding orphaned session files (on disk but not in index)
    - Detecting dead entries (in index but no file on disk)
    - Fixing customTitle collisions (append date suffix)
    - Backing up before modifications
    
    Usage:
        python repair-sessions-index.py              # Dry-run (report only)
        python repair-sessions-index.py --fix        # Apply fixes
        python repair-sessions-index.py --verbose    # Detailed output
        python repair-sessions-index.py --hook       # SessionStart hook mode
        python repair-sessions-index.py --quiet      # Silent when nothing to fix
    """
    
    import argparse
    import io
    import json
    import re
    import sys
    from collections import defaultdict
    from datetime import datetime, timezone
    from pathlib import Path
    from typing import Any
    
    # Fix Windows cp1252 encoding for Unicode output
    if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
    
    UUID_PATTERN = re.compile(
        r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$",
        re.IGNORECASE
    )
    
    MAX_VERBOSE_SESSIONS = 10
    MAX_DEFAULT_SESSIONS = 5
    
    
    def format_size(size_bytes: int) -> str:
        if size_bytes < 1024:
            return f"{size_bytes}B"
        elif size_bytes < 1024 * 1024:
            return f"{size_bytes // 1024}KB"
        else:
            return f"{size_bytes / (1024 * 1024):.1f}MB"
    
    
    def parse_session_file(session_path: Path) -> dict[str, Any] | None:
        """Parse a session JSONL file to extract metadata."""
        try:
            lines = session_path.read_text(encoding="utf-8").splitlines()
        except (OSError, UnicodeDecodeError) as e:
            print(f"  Failed to read {session_path.name}: {e}", file=sys.stderr)
            return None
    
        if not lines:
            return None
    
        session_id = None
        first_prompt = "No prompt"
        custom_title = None
        message_count = 0
        created = None
        modified = None
        git_branch = None
        is_sidechain = False
    
        for line in lines:
            if not line.strip():
                continue
            try:
                data = json.loads(line)
            except json.JSONDecodeError:
                continue
    
            if not session_id and "sessionId" in data:
                session_id = data["sessionId"]
            if git_branch is None and "gitBranch" in data:
                git_branch = data.get("gitBranch", "main")
            if "isSidechain" in data:
                is_sidechain = data.get("isSidechain", False)
            if "timestamp" in data:
                ts = data["timestamp"]
                if not created:
                    created = ts
                modified = ts
            if data.get("type") in ("user", "assistant", "tool_use", "tool_result"):
                message_count += 1
            if data.get("type") == "user" and first_prompt == "No prompt":
                message = data.get("message", {})
                content = message.get("content", "")
                if isinstance(content, str) and content.strip():
                    first_prompt = content[:80].strip()
                    if len(content) > 80:
                        first_prompt += "..."
            if data.get("type") == "custom-title":
                custom_title = data.get("customTitle")
    
        if not session_id:
            session_id = session_path.stem
    
        return {
            "sessionId": session_id,
            "fullPath": str(session_path.absolute()),
            "fileMtime": int(session_path.stat().st_mtime * 1000),
            "firstPrompt": first_prompt,
            "customTitle": custom_title,
            "summary": first_prompt,
            "messageCount": message_count,
            "created": created or datetime.fromtimestamp(
                session_path.stat().st_ctime, tz=timezone.utc
            ).isoformat(),
            "modified": modified or datetime.fromtimestamp(
                session_path.stat().st_mtime, tz=timezone.utc
            ).isoformat(),
            "gitBranch": git_branch or "main",
            "projectPath": str(session_path.parent.parent.absolute()),
            "isSidechain": is_sidechain,
        }
    
    
    def find_orphaned_sessions(project_dir: Path, index_data: dict) -> list[dict]:
        indexed_ids = {entry["sessionId"] for entry in index_data.get("entries", [])}
        orphaned = []
        for session_file in project_dir.glob("*.jsonl"):
            if not UUID_PATTERN.match(session_file.name):
                continue
            if session_file.stem not in indexed_ids:
                metadata = parse_session_file(session_file)
                if metadata:
                    orphaned.append(metadata)
        orphaned.sort(key=lambda x: x.get("created", ""), reverse=True)
        return orphaned
    
    
    def find_dead_entries(project_dir: Path, index_data: dict) -> list[dict]:
        return [
            entry for entry in index_data.get("entries", [])
            if not Path(entry["fullPath"]).exists()
        ]
    
    
    def detect_title_collisions(entries: list[dict]) -> dict[str, list[dict]]:
        title_map = defaultdict(list)
        for entry in entries:
            ct = entry.get("customTitle")
            if ct:
                title_map[ct].append(entry)
        return {t: e for t, e in title_map.items() if len(e) > 1}
    
    
    def fix_title_collisions(entries: list[dict]) -> None:
        for title, dupes in detect_title_collisions(entries).items():
            dupes.sort(key=lambda x: x.get("created", ""))
            for entry in dupes[1:]:
                created = entry.get("created", "")
                try:
                    dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
                    suffix = dt.strftime("%Y-%m-%d")
                except (ValueError, AttributeError, TypeError):
                    suffix = "unknown"
                entry["customTitle"] = f"{title} ({suffix})"
    
    
    def backup_index(index_path: Path) -> Path:
        backup_path = index_path.with_suffix(".json.bak")
        backup_path.write_text(
            index_path.read_text(encoding="utf-8"), encoding="utf-8"
        )
        return backup_path
    
    
    def repair_project(project_dir: Path, fix: bool, verbose: bool) -> dict:
        index_path = project_dir / "sessions-index.json"
        if not index_path.exists():
            return {"error": f"sessions-index.json not found in {project_dir}"}
    
        try:
            index_data = json.loads(index_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as e:
            return {"error": f"Failed to read index: {e}"}
    
        existing = index_data.get("entries", [])
        orphaned = find_orphaned_sessions(project_dir, index_data)
        dead = find_dead_entries(project_dir, index_data)
        disk_count = len([
            f for f in project_dir.glob("*.jsonl")
            if UUID_PATTERN.match(f.name)
        ])
        collisions = detect_title_collisions(existing + orphaned)
    
        stats = {
            "project_dir": str(project_dir),
            "indexed": len(existing),
            "on_disk": disk_count,
            "orphaned": len(orphaned),
            "dead": len(dead),
            "collisions": len(collisions),
            "collision_entries": sum(len(e) for e in collisions.values()),
            "orphaned_sessions": orphaned[
                :MAX_VERBOSE_SESSIONS if verbose else MAX_DEFAULT_SESSIONS
            ],
        }
    
        if fix:
            stats["backup"] = str(backup_index(index_path))
            cleaned = [e for e in existing if e not in dead]
            merged = cleaned + orphaned
            fix_title_collisions(merged)
            merged.sort(key=lambda x: x.get("created", ""), reverse=True)
            index_data["entries"] = merged
            index_path.write_text(
                json.dumps(index_data, indent=2, ensure_ascii=False),
                encoding="utf-8",
            )
            stats["fixed"] = True
            stats["new_total"] = len(merged)
    
        return stats
    
    
    def run_hook_mode() -> None:
        """Run as SessionStart hook - auto-fix silently, output hook JSON."""
        try:
            sys.stdin.read()
        except Exception:
            pass
    
        claude_dir = Path.home() / ".claude" / "projects"
        if not claude_dir.exists():
            sys.stdout.write('{"continue":true,"suppressOutput":true}')
            return
    
        total_fixed = 0
        for d in claude_dir.iterdir():
            if d.is_dir() and (d / "sessions-index.json").exists():
                stats = repair_project(d, fix=True, verbose=False)
                if stats.get("fixed") and stats.get("orphaned", 0) > 0:
                    total_fixed += stats["orphaned"]
    
        if total_fixed > 0:
            print(
                f"[session-repair] Re-indexed {total_fixed} orphaned session(s)",
                file=sys.stderr,
            )
        sys.stdout.write('{"continue":true,"suppressOutput":true}')
    
    
    def main() -> None:
        ap = argparse.ArgumentParser(
            description="Repair Claude Code sessions-index.json"
        )
        ap.add_argument("--fix", action="store_true")
        ap.add_argument("--verbose", action="store_true")
        ap.add_argument("--quiet", action="store_true")
        ap.add_argument("--hook", action="store_true")
        ap.add_argument("--project", type=str)
        args = ap.parse_args()
    
        if args.hook:
            return run_hook_mode()
    
        claude_dir = Path.home() / ".claude" / "projects"
        if not claude_dir.exists():
            sys.exit("Claude projects directory not found")
    
        if args.project:
            dirs = [claude_dir / args.project]
        else:
            dirs = [
                d for d in claude_dir.iterdir()
                if d.is_dir() and (d / "sessions-index.json").exists()
            ]
    
        totals = {"orphaned": 0, "dead": 0, "collisions": 0}
        results = []
    
        for d in dirs:
            s = repair_project(d, args.fix, args.verbose)
            results.append((d, s))
            if "error" not in s:
                for k in totals:
                    totals[k] += s[k]
    
        if args.quiet and all(v == 0 for v in totals.values()):
            return
    
        print("Session Index Repair Report")
        print("=" * 60)
        if not args.fix:
            print("DRY-RUN MODE (use --fix to apply changes)\n")
    
        for d, s in results:
            if "error" in s:
                print(f"\n{d.name}: {s['error']}", file=sys.stderr)
                continue
            print(f"\nProject: {d.name}")
            print(f"  Indexed: {s['indexed']}, On disk: {s['on_disk']}")
            print(f"  Orphaned: {s['orphaned']}, Dead: {s['dead']}")
            print(f"  Collisions: {s['collisions']}")
            if args.fix and s.get("fixed"):
                print(f"  -> Repaired ({s['new_total']} total)")
    
        print(f"\n{'='*60}")
        print(f"Totals: {totals}")
        if not args.fix and any(v > 0 for v in totals.values()):
            print("Run with --fix to repair")
    
    
    if __name__ == "__main__":
        main()

    Usage

    # One-time fix (dry-run first, then apply)
    python repair-sessions-index.py
    python repair-sessions-index.py --fix
    
    # Auto-fix on every session start via hook (see below)

    Auto-fix via SessionStart hook

    Add to your ~/.claude/settings.json to auto-repair on every CC launch:

    {
      "hooks": {
        "SessionStart": [
          {
            "hooks": [
              {
                "type": "command",
                "command": "python /path/to/repair-sessions-index.py --hook",
                "timeout": 15
              }
            ]
          }
        ]
      }
    }

    The --hook flag consumes stdin, auto-fixes silently, and outputs {"continue":true,"suppressOutput":true}. Only prints to stderr when repairs are made. Index is repaired before your prompt appears — so --resume always works against a current index.

    Note from #23614: In v2.1.34, the /resume picker may scan .jsonl files by recency instead of reading sessions-index.json. The index repair still helps for claude --resume "name" (CLI flag) which matches against customTitle.

  4. tracymelody commented on Feb 11, 2026

    @tracymelody

    Confirming: sessions-index.json is completely unused by /resume in v2.1.31+

    @Sajakhtar's finding is correct. After reverse-engineering the minified cli.js (v2.1.39), I can confirm the full mechanism:

    How /resume actually loads sessions

    1. dd1(projectDir) — calls readdirSync on the project directory, filters for UUID-named .jsonl files, stats each one for mtime/ctime/size
    2. Pf1 — sorts by mtime descending, creates "lite" entries (no content loaded yet)
    3. MY1 — iterates through sorted entries, calls jwz → Xwz to lazily read the first 16KB (for firstPrompt, isSidechain, teamName) and last 16KB (for customTitle, tag, gitBranch) of each .jsonl file
    4. Sessions are filtered out if: no firstPrompt AND no customTitle, or if isSidechain/teamName is set

    sessions-index.json is never read. The string doesn't even appear in the source.

    The real bug is pagination

    The picker loads only 10 sessions initially (hardcoded in MY1). More should load via onLoadMore when scrolling, but the load-more trigger depends on terminal height and highlight position interacting correctly:

    page_size = Math.floor((terminal_rows - ~10) / 3)
    load_more_when: highlight_pos + (page_size * 2) >= loaded_count
    

    With small terminals (24 rows → page_size=4), the load-more effect doesn't reliably fire because the Ink SelectInput wraps at the list boundary instead of pushing the highlight position high enough. See my detailed analysis on #24435.

    Workaround

    claude --resume <keyword> bypasses the picker entirely and searches all session files on disk directly. This works reliably regardless of terminal size.

    Rebuilding sessions-index.json (as many of us tried) has no effect on /resume behavior.

  5. bosmadev commented on Feb 12, 2026

    @bosmadev

    Update: Published the repair script and SessionStart hook as part of my open-source Claude Code configuration:

    Repo: bosmadev/claude

    • scripts/repair-sessions-index.py — finds orphaned .jsonl session files and re-indexes them
    • Registered as SessionStart hook for auto-repair on every launch
    • Uses ACID transactional primitives (hooks/transaction.py) for safe concurrent writes with OCC

    Note: per @tracymelody's finding above, /resume in v2.1.31+ reads .jsonl files directly from disk (readdirSync) rather than relying on sessions-index.json. The index file may only matter for the claude -c flag and older versions. The repair script still helps for tooling that reads the index (status line, /chats skill, etc).

  6. hi-fox commented on Feb 13, 2026

    @hi-fox

    @claude please prioritise this issue, not having access to session history is a critical bug

  7. scapeshift-ojones commented on Feb 16, 2026

    @scapeshift-ojones

    This issue is tracked in the consolidated report at #26123, which identifies 3 distinct root causes (index writes stopped Feb 4, picker hardcoded to 10-session batch, Windows worktree case-sensitivity) with source-level analysis and a one-line fix. Please add your thumbs-up there to help it reach the oncall triage threshold.

  8. scapeshift-ojones commented on Feb 16, 2026

    @scapeshift-ojones

    Your 👍 on the consolidated issue matters. Based on how this repo's automated triage works, issues need 50+ combined reactions and comments to trigger the oncall label — the only way a human at Anthropic actually reviews it. Right now the engagement is split across 12+ duplicate issues and none will ever hit that threshold alone.

    The consolidated issue with full root cause analysis (3 bugs identified, one-line fix included) is here: #26123

    Please go add your 👍 there. That's the single most useful thing you can do to get this fixed.

  9. Sajakhtar commented on Feb 17, 2026

    @Sajakhtar
    Author

    closing since this is consolidated into #26123

  10. scapeshift-ojones commented on Feb 17, 2026

    @scapeshift-ojones

    We're at 22 👍 on the consolidated issue — more than halfway to the 50 needed to get a human at Anthropic to look at this. Every thumbs-up on a duplicate issue is a thumbs-up that doesn't count. The automated triage bot only checks individual issues, not the cluster.

    Please take 5 seconds to 👍 here: #26123

    The root causes are fully identified, a one-line fix exists, and community repair scripts are available. The only thing missing is enough engagement on a single issue to cross the oncall threshold. We can get this fixed if we stop splitting our votes across 12 separate reports.

  11. github-actions commented on Feb 25, 2026

    @github-actions

    This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.

  12. locked as resolved and limited conversation to collaborators on Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions