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

Status Fixed / completed
Reported on v2.1.31
Maintainer reply None cached
Activity 11 comments · opened Feb 6, 2026 · closed Feb 17, 2026

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

  • #22030 — similar symptom (stale sessions-index.json) but different root cause (metadata drift vs index not being written at all)
  • #14157 — similar pattern of /resume regression after version update
  • #18311 — sessions exist on disk but not discoverable

View original on GitHub ↗

11 Comments

github-actions[bot] · 6 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/23421
  2. https://github.com/anthropics/claude-code/issues/22462
  3. https://github.com/anthropics/claude-code/issues/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

Sajakhtar · 6 months ago

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.

bosmadev · 6 months ago

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)

<details>
<summary>Click to expand full script (~250 lines)</summary>

#!/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()

</details>

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.

tracymelody · 6 months ago

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 jwzXwz 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.

bosmadev · 6 months ago

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).

hi-fox · 6 months ago

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

scapeshift-ojones · 6 months ago

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.

scapeshift-ojones · 6 months ago

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.

Sajakhtar · 6 months ago

closing since this is consolidated into https://github.com/anthropics/claude-code/issues/26123

scapeshift-ojones · 6 months ago

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.

github-actions[bot] · 6 months ago

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.