[BUG] API Error: 400 reasoning `encrypted_content` was not issued to this caller

Status Open
Reported on v2.1.241
Maintainer reply None cached
Activity 4 comments · opened Aug 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

So when i change the API key things while at machine level the claude code break and shows this error API Error: 400 reasoning encrypted_content was not issued to this caller

What Should Happen?

I have maked an python script to fix this and it can be used via the fix-claude i have the code if any one have this issue please let me know so i can just make an repo and publish the code so you can use to fix it

Error Messages/Logs

API Error: 400 reasoning `encrypted_content` was not issued to this caller

Steps to Reproduce

when i change the API keys at system level in windows 11 it brokes the claude chat and shows the error and i have fixed it via the python script

Claude Model

Other

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.241 (Claude Code)

Platform

Other

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

_No response_

View original on GitHub ↗

4 Comments

chanmilee-fe · 5 days ago

Not from Anthropic, just another user, but this might be the same thing as #49994. Encrypted content blocks can't be read by the client, only replayed as-is on later turns. Claude Code keeps them in the session transcript and resends them on every request, so a block issued under the old key goes out under the new one and gets rejected. That's why it stays broken on every prompt afterwards instead of failing once.

Try running /clear in that session, or just start a new one. Don't reopen the old session with --continue or --resume, since that reloads the same transcript. You shouldn't need to patch any files. Does that work on the new key?

hariomlohardev · 5 days ago

via /clear it works but if i have something important then i have to export it and its not that much efficent and i dont think its an corect way so i pursued this way and just want to share if any one other have that problem

chanmilee-fe · 4 days ago

Agreed, /clear isn't a fix, it just tells us where the problem is. A fresh session working means the new key is fine and the failure is purely the old transcript being replayed. Heads up that /compact probably won't save you either: in #49994 it returned the same 400, so it can't clear the bad block.

If you can say which blocks your script strips out, that would make this much easier to fix properly. That's the part that pins down what Claude Code is resending, and the actual fix is for it to drop those blocks when the credential changes instead of replaying them forever.

hariomlohardev · 4 days ago

listen instead of that look at this python code that fixed the issue that i have having

#!/usr/bin/env python3
"""
fix-claude -- repair a Claude Code session that dies with:

    API Error: 400 reasoning `encrypted_content` was not issued to this caller

Cause
-----
Thinking / redacted_thinking blocks carry an encrypted payload cryptographically
bound to the API key + org that produced them. Rotate your key mid-session and
the transcript still replays those old blocks under the new key -> 400.

Fix
---
Strip `thinking` / `redacted_thinking` blocks from EARLIER assistant turns in the
session transcript. Anthropic's docs allow omitting thinking blocks from previous
turns. The LAST assistant turn is left untouched by default, because the API
requires the most recent assistant message's thinking blocks to come back
byte-for-byte unmodified when it is mid tool-use loop.

Usage
-----
    fix-claude                 # fix the newest session for the current directory
    fix-claude --list          # just show the sessions, change nothing
    fix-claude --dry-run       # show exactly what would change
    fix-claude --session <id>  # target a specific session UUID
    fix-claude --dir <path>    # target a different project directory
    fix-claude --all           # also strip the last assistant turn (see warning)
    fix-claude --placeholder   # substitute text for turns left with empty content
    fix-claude --restore       # roll back to the newest backup

Every run backs the transcript up to <session>.jsonl.bak-<timestamp> first and
verifies the rewritten file (line count, uuid chain, roles, non-thinking blocks)
before anything is replaced. If verification fails, nothing is touched.
"""

from __future__ import annotations

import argparse
import datetime as _dt
import json
import os
import re
import shutil
import sys
from pathlib import Path

__version__ = "1.1.0"

THINKING_TYPES = ("thinking", "redacted_thinking")
SKIP_SUFFIXES = (".bak", ".original", ".fixed", ".tmp", ".broken")

# Real transcripts usually give each streamed block its own JSONL entry, so an
# assistant line often holds nothing but a thinking block. Stripping it leaves
# `content: []`. Claude Code skips those entries when it rebuilds the request,
# which is the behaviour verified in practice -- so empty is the default.
# --placeholder swaps in this block instead, for the rare case where empty
# content reaches the API and gets rejected.
PLACEHOLDER = {"type": "text", "text": "(reasoning block omitted)"}

# If the transcript was written to more recently than this, assume Claude Code
# may still have the session open and ask before replacing it.
LIVE_SESSION_SECONDS = 120


# --------------------------------------------------------------------------- #
# pretty output
# --------------------------------------------------------------------------- #

class C:
    """ANSI colours, blanked out when the terminal can't take them."""
    RESET = "\033[0m"
    BOLD = "\033[1m"
    DIM = "\033[2m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    BLUE = "\033[34m"
    MAGENTA = "\033[35m"
    CYAN = "\033[36m"
    GREY = "\033[90m"

    @classmethod
    def disable(cls) -> None:
        for name in dir(cls):
            if name.isupper():
                setattr(cls, name, "")


def _enable_ansi() -> bool:
    """Turn on VT processing on Windows consoles. Returns True if colour is OK."""
    if os.environ.get("NO_COLOR"):
        return False
    if not sys.stdout.isatty():
        return False
    if os.name != "nt":
        return True
    try:
        import ctypes

        kernel32 = ctypes.windll.kernel32
        # -11 == STD_OUTPUT_HANDLE, 0x4 == ENABLE_VIRTUAL_TERMINAL_PROCESSING
        handle = kernel32.GetStdHandle(-11)
        mode = ctypes.c_uint32()
        if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
            return False
        kernel32.SetConsoleMode(handle, mode.value | 0x4)
        return True
    except Exception:
        return False


_UNICODE_OK = True


def _s(fancy: str, plain: str) -> str:
    return fancy if _UNICODE_OK else plain


def rule(char: str = "-", width: int = 66) -> str:
    return C.GREY + char * width + C.RESET


def banner() -> None:
    line = _s("\u2500", "-") * 66
    print()
    print(f"{C.CYAN}{line}{C.RESET}")
    print(f"{C.BOLD}{C.CYAN}  fix-claude{C.RESET}{C.GREY}  v{__version__}"
          f"   -- Claude Code session repair{C.RESET}")
    print(f"{C.GREY}  strips stale encrypted reasoning blocks after an API key change{C.RESET}")
    print(f"{C.CYAN}{line}{C.RESET}")


def step(n, total, text) -> None:
    print(f"{C.BLUE}{C.BOLD}[{n}/{total}]{C.RESET} {C.BOLD}{text}{C.RESET}")


def info(label: str, value: str) -> None:
    print(f"      {C.GREY}{label:<20}{C.RESET}{value}")


def ok(text: str) -> None:
    print(f"  {C.GREEN}{_s('\u2714', 'OK')}{C.RESET} {text}")


def warn(text: str) -> None:
    print(f"  {C.YELLOW}{_s('\u26a0', '!!')}{C.RESET} {C.YELLOW}{text}{C.RESET}")


def fail(text: str) -> None:
    print(f"  {C.RED}{_s('\u2718', 'XX')}{C.RESET} {C.RED}{text}{C.RESET}")


def note(text: str) -> None:
    print(f"    {C.GREY}{text}{C.RESET}")


def die(text: str, code: int = 1):
    print()
    fail(text)
    print()
    sys.exit(code)


def human_size(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024.0
    return f"{n}"


def human_age(ts: float) -> str:
    delta = _dt.datetime.now() - _dt.datetime.fromtimestamp(ts)
    secs = int(delta.total_seconds())
    if secs < 60:
        return f"{secs}s ago"
    if secs < 3600:
        return f"{secs // 60}m ago"
    if secs < 86400:
        return f"{secs // 3600}h ago"
    return f"{secs // 86400}d ago"


# --------------------------------------------------------------------------- #
# locating the session
# --------------------------------------------------------------------------- #

def munge_project_path(path: Path) -> str:
    """
    Claude Code maps a working directory to a folder under ~/.claude/projects
    by replacing every character outside [A-Za-z0-9-] with '-'.

        D:\\Claude code\\curriculum-system  ->  D--Claude-code-curriculum-system
        C:\\Users\\hario\\.claude\\system    ->  C--Users-hario--claude-system
    """
    raw = str(path)
    raw = raw.rstrip("\\/")
    return re.sub(r"[^A-Za-z0-9-]", "-", raw)


def claude_projects_root() -> Path:
    override = os.environ.get("CLAUDE_CONFIG_DIR")
    base = Path(override) if override else Path.home() / ".claude"
    return base / "projects"


def resolve_project_dir(cwd: Path) -> tuple[Path, str]:
    """Return (project_dir, how_it_was_found)."""
    root = claude_projects_root()
    if not root.is_dir():
        die(f"No Claude projects directory at {root}")

    munged = munge_project_path(cwd)
    exact = root / munged
    if exact.is_dir():
        return exact, "exact match"

    # Try the resolved / absolute form of the path too.
    alt = munge_project_path(cwd.resolve())
    if alt != munged and (root / alt).is_dir():
        return root / alt, "resolved-path match"

    # Case-insensitive fallback (Windows paths vary in case).
    lowered = munged.lower()
    for child in root.iterdir():
        if child.is_dir() and child.name.lower() == lowered:
            return child, "case-insensitive match"

    print()
    fail(f"No session folder for this directory.")
    note(f"looked for : {munged}")
    note(f"under      : {root}")
    print()
    print(f"  {C.GREY}Available project folders:{C.RESET}")
    entries = sorted(
        (c for c in root.iterdir() if c.is_dir()),
        key=lambda c: c.stat().st_mtime,
        reverse=True,
    )
    for child in entries[:15]:
        print(f"    {C.GREY}{human_age(child.stat().st_mtime):>9}{C.RESET}  {child.name}")
    if len(entries) > 15:
        note(f"... and {len(entries) - 15} more")
    print()
    note("Run from the project directory, or pass --dir <path>.")
    print()
    sys.exit(1)


def list_sessions(project_dir: Path) -> list[Path]:
    files = [
        p for p in project_dir.glob("*.jsonl")
        if not any(s in p.name for s in SKIP_SUFFIXES)
    ]
    return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)


# --------------------------------------------------------------------------- #
# transcript analysis
# --------------------------------------------------------------------------- #

class Scan:
    def __init__(self) -> None:
        self.total_lines = 0
        self.parsed = 0
        self.unparseable: list[int] = []
        self.assistant_lines: list[int] = []
        self.last_assistant_line: int | None = None
        self.last_main_assistant_line: int | None = None
        self.thinking: list[tuple[int, list[str], bool]] = []  # (line, types, sidechain)
        self.block_count = 0


def scan(path: Path) -> Scan:
    s = Scan()
    with path.open("r", encoding="utf-8", errors="replace") as f:
        for i, raw in enumerate(f, start=1):
            s.total_lines = i
            stripped = raw.strip()
            if not stripped:
                continue
            try:
                obj = json.loads(stripped)
            except json.JSONDecodeError:
                s.unparseable.append(i)
                continue
            s.parsed += 1
            if not isinstance(obj, dict):
                continue
            msg = obj.get("message")
            if not isinstance(msg, dict) or msg.get("role") != "assistant":
                continue

            sidechain = bool(obj.get("isSidechain"))
            s.assistant_lines.append(i)
            s.last_assistant_line = i
            if not sidechain:
                s.last_main_assistant_line = i

            content = msg.get("content")
            if isinstance(content, list):
                types = [
                    b.get("type") for b in content
                    if isinstance(b, dict) and b.get("type") in THINKING_TYPES
                ]
                if types:
                    s.thinking.append((i, types, sidechain))
                    s.block_count += len(types)
    return s


def protected_lines(s: Scan, strip_all: bool) -> set[int]:
    if strip_all:
        return set()
    keep = set()
    if s.last_assistant_line is not None:
        keep.add(s.last_assistant_line)
    if s.last_main_assistant_line is not None:
        keep.add(s.last_main_assistant_line)
    return keep


# --------------------------------------------------------------------------- #
# rewriting
# --------------------------------------------------------------------------- #

def rewrite(path: Path, out: Path, keep: set[int],
            use_placeholder: bool = False) -> dict:
    """
    Rewrite the transcript, stripping thinking blocks from every assistant
    message except the protected lines. Lines that need no change are copied
    through byte-for-byte -- only modified lines are re-serialised.
    """
    stats = {"lines_written": 0, "lines_changed": 0, "blocks_removed": 0,
             "emptied": 0, "changed_lines": []}

    with path.open("r", encoding="utf-8", errors="replace") as fin, \
         out.open("w", encoding="utf-8", newline="\n") as fout:
        for i, raw in enumerate(fin, start=1):
            line = raw.rstrip("\r\n")
            passthrough = True

            if line.strip() and i not in keep:
                try:
                    obj = json.loads(line)
                except json.JSONDecodeError:
                    obj = None
                if isinstance(obj, dict):
                    msg = obj.get("message")
                    if isinstance(msg, dict) and msg.get("role") == "assistant":
                        content = msg.get("content")
                        if isinstance(content, list):
                            kept = [
                                b for b in content
                                if not (isinstance(b, dict)
                                        and b.get("type") in THINKING_TYPES)
                            ]
                            removed = len(content) - len(kept)
                            if removed:
                                if not kept:
                                    stats["emptied"] += 1
                                    if use_placeholder:
                                        kept = [dict(PLACEHOLDER)]
                                msg["content"] = kept
                                stats["blocks_removed"] += removed
                                stats["lines_changed"] += 1
                                stats["changed_lines"].append(i)
                                fout.write(
                                    json.dumps(obj, ensure_ascii=False,
                                               separators=(",", ":")) + "\n"
                                )
                                passthrough = False

            if passthrough:
                fout.write(line + "\n")
            stats["lines_written"] += 1

    return stats


def verify(original: Path, candidate: Path, expect_changed: set[int],
           use_placeholder: bool = False) -> list[str]:
    """Structural safety check on the rewritten file. Returns list of problems."""
    problems: list[str] = []

    with original.open("r", encoding="utf-8", errors="replace") as a, \
         candidate.open("r", encoding="utf-8", errors="replace") as b:
        for i, (la, lb) in enumerate(zip(a, b), start=1):
            la, lb = la.rstrip("\r\n"), lb.rstrip("\r\n")

            if not la.strip():
                continue

            try:
                oa = json.loads(la)
            except json.JSONDecodeError:
                if la != lb:
                    problems.append(f"line {i}: unparseable line was altered")
                continue

            try:
                ob = json.loads(lb)
            except json.JSONDecodeError:
                problems.append(f"line {i}: output no longer parses as JSON")
                continue

            if not isinstance(oa, dict) or not isinstance(ob, dict):
                continue

            if oa.get("uuid") != ob.get("uuid"):
                problems.append(f"line {i}: uuid changed")
            if oa.get("parentUuid") != ob.get("parentUuid"):
                problems.append(f"line {i}: parentUuid changed")
            if oa.get("type") != ob.get("type"):
                problems.append(f"line {i}: entry type changed")

            ma, mb = oa.get("message"), ob.get("message")
            if isinstance(ma, dict) and isinstance(mb, dict):
                if ma.get("role") != mb.get("role"):
                    problems.append(f"line {i}: role changed")
                ca, cb = ma.get("content"), mb.get("content")
                if isinstance(ca, list) and isinstance(cb, list):
                    surviving = [
                        blk for blk in ca
                        if not (isinstance(blk, dict)
                                and blk.get("type") in THINKING_TYPES)
                    ]
                    if i in expect_changed:
                        expected = surviving or ([dict(PLACEHOLDER)]
                                                 if use_placeholder else [])
                        if cb != expected:
                            problems.append(
                                f"line {i}: non-thinking blocks were altered")
                    elif ca != cb:
                        problems.append(f"line {i}: content changed unexpectedly")

    # line counts must match exactly
    na = sum(1 for _ in original.open("r", encoding="utf-8", errors="replace"))
    nb = sum(1 for _ in candidate.open("r", encoding="utf-8", errors="replace"))
    if na != nb:
        problems.append(f"line count changed: {na} -> {nb}")

    return problems


# --------------------------------------------------------------------------- #
# backups
# --------------------------------------------------------------------------- #

def backup_path(session: Path) -> Path:
    stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    return session.with_name(session.name + f".bak-{stamp}")


def find_backups(session: Path) -> list[Path]:
    """Newest first, ordered by the timestamp baked into the filename."""
    pattern = session.name + ".bak-*"
    found = sorted(session.parent.glob(pattern), key=lambda p: p.name, reverse=True)
    legacy = session.with_name(session.name + ".bak")
    if legacy.exists():
        found.append(legacy)
    return found


def backup_stamp(path: Path) -> str:
    """Human-readable creation time pulled from the .bak-YYYYmmdd-HHMMSS suffix."""
    m = re.search(r"\.bak-(\d{8})-(\d{6})$", path.name)
    if not m:
        return human_age(path.stat().st_mtime)
    try:
        when = _dt.datetime.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")
    except ValueError:
        return human_age(path.stat().st_mtime)
    return f"{when:%Y-%m-%d %H:%M:%S}  ({human_age(when.timestamp())})"


def prune_backups(session: Path, keep: int) -> list[Path]:
    if keep <= 0:
        return []
    backups = find_backups(session)
    removed = []
    for old in backups[keep:]:
        try:
            old.unlink()
            removed.append(old)
        except OSError:
            pass
    return removed


# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #

def cmd_list(project_dir: Path, how: str) -> int:
    sessions = list_sessions(project_dir)
    step(1, 1, "Sessions for this project")
    info("project folder", project_dir.name)
    info("matched by", how)
    print()
    if not sessions:
        warn("No session transcripts found here.")
        return 1

    hdr = f"      {C.GREY}{'':<3}{'session id':<38}{'size':>9}  {'modified':>10}  thinking{C.RESET}"
    print(hdr)
    print(f"      {C.GREY}{_s('\u2500', '-') * 76}{C.RESET}")
    for idx, path in enumerate(sessions[:12]):
        s = scan(path)
        marker = f"{C.GREEN}{_s('\u25b8', '>')}{C.RESET}" if idx == 0 else " "
        blocks = s.block_count
        blob = (f"{C.YELLOW}{blocks:>4}{C.RESET}" if blocks
                else f"{C.GREY}   -{C.RESET}")
        print(f"      {marker}  {path.stem:<38}"
              f"{human_size(path.stat().st_size):>9}  "
              f"{human_age(path.stat().st_mtime):>10}  {blob}")
    if len(sessions) > 12:
        note(f"... and {len(sessions) - 12} more")
    print()
    note(f"{_s('\u25b8', '>')} marks the newest session -- the one fix-claude targets by default.")
    return 0


def cmd_restore(session: Path, assume_yes: bool) -> int:
    backups = find_backups(session)
    step(1, 2, "Locate backup")
    if not backups:
        die(f"No backups found for {session.name}")
    newest = backups[0]
    info("session", session.name)
    info("newest backup", newest.name)
    info("taken", backup_stamp(newest))
    info("size", human_size(newest.stat().st_size))
    if len(backups) > 1:
        note(f"{len(backups) - 1} older backup(s) also available in this folder")
    print()

    if not assume_yes:
        print(f"  {C.YELLOW}This overwrites the current transcript with the backup.{C.RESET}")
        reply = input(f"  {C.BOLD}Restore? [y/N] {C.RESET}").strip().lower()
        if reply not in ("y", "yes"):
            print()
            note("Aborted. Nothing changed.")
            return 1
        print()

    step(2, 2, "Restore")
    pre = session.with_name(session.name + ".pre-restore")
    if session.exists():
        shutil.copy2(session, pre)
        note(f"current file saved as {pre.name}")
    shutil.copy2(newest, session)
    ok(f"Restored from {C.BOLD}{newest.name}{C.RESET}")
    print()
    return 0


def cmd_fix(session: Path, project_dir: Path, how: str, args) -> int:
    total_steps = 3 if args.dry_run else 5

    # ---- 1. inspect ------------------------------------------------------- #
    step(1, total_steps, "Inspect transcript")
    info("project folder", project_dir.name)
    info("matched by", how)
    info("session", session.stem)
    info("size", human_size(session.stat().st_size))
    info("last modified", human_age(session.stat().st_mtime))

    s = scan(session)
    print()
    info("total lines", str(s.total_lines))
    info("assistant turns", str(len(s.assistant_lines)))
    info("last assistant", f"line {s.last_assistant_line}"
                           if s.last_assistant_line else "none")
    if s.unparseable:
        warn(f"{len(s.unparseable)} line(s) do not parse as JSON "
             f"(first: line {s.unparseable[0]}) -- they will be copied through untouched")
    print()

    if not s.thinking:
        ok("No thinking / redacted_thinking blocks in this transcript.")
        print()
        note("Nothing to strip. If you are still seeing the 400 error, the stale")
        note("block may live in a different session -- try:  fix-claude --list")
        print()
        return 0

    keep = protected_lines(s, args.all)
    targets = [(ln, ty, sc) for ln, ty, sc in s.thinking if ln not in keep]
    skipped = [(ln, ty, sc) for ln, ty, sc in s.thinking if ln in keep]
    blocks_to_remove = sum(len(ty) for ln, ty, sc in targets)

    print(f"      {C.GREY}{'line':>7}  {'blocks':<28}{'chain':<12}action{C.RESET}")
    print(f"      {C.GREY}{_s('\u2500', '-') * 62}{C.RESET}")
    shown = s.thinking[-args.show:] if args.show > 0 else s.thinking
    for ln, types, sidechain in shown:
        chain = "subagent" if sidechain else "main"
        if ln in keep:
            action = f"{C.YELLOW}KEEP (latest turn){C.RESET}"
        else:
            action = f"{C.GREEN}strip{C.RESET}"
        label = ", ".join(types)
        if len(label) > 26:
            label = label[:23] + "..."
        print(f"      {ln:>7}  {label:<28}{C.GREY}{chain:<12}{C.RESET}{action}")
    if args.show > 0 and len(s.thinking) > args.show:
        note(f"({len(s.thinking) - args.show} earlier entries not shown -- "
             f"use --show 0 for all)")
    print()

    info("blocks found", str(s.block_count))
    info("to strip", f"{C.GREEN}{blocks_to_remove}{C.RESET}")
    info("preserved", f"{C.YELLOW}{sum(len(t) for _, t, _ in skipped)}{C.RESET}"
                      f"{C.GREY}  (latest assistant turn){C.RESET}"
         if skipped else f"{C.GREY}0{C.RESET}")
    print()

    if args.all:
        warn("--all: the latest assistant turn will be stripped too.")
        note("Safe when that turn finished normally. If the session was killed")
        note("mid tool-call, this can trade the 400 for a different error --")
        note("roll back with:  fix-claude --restore")
        print()

    if not targets:
        warn("Every thinking block sits in the latest assistant turn.")
        print()
        note("Stripping those is the one edit the API rejects outright, so the")
        note("default run leaves them alone. If that turn completed normally you")
        note("can force it:")
        print(f"    {C.BOLD}fix-claude --all{C.RESET}")
        print()
        return 2

    # ---- 2. build the repaired file --------------------------------------- #
    step(2, total_steps, "Build repaired transcript")
    tmp = session.with_name(session.name + ".tmp")
    stats = rewrite(session, tmp, keep, use_placeholder=args.placeholder)
    ok(f"Rewrote {C.BOLD}{stats['lines_changed']}{C.RESET} line(s), "
       f"removed {C.BOLD}{stats['blocks_removed']}{C.RESET} block(s)")
    if stats["emptied"]:
        if args.placeholder:
            note(f"{stats['emptied']} thinking-only turn(s) given a placeholder "
                 f"text block (--placeholder)")
        else:
            note(f"{stats['emptied']} thinking-only turn(s) now have empty content "
                 f"-- Claude Code skips those on replay")
    print()

    # ---- 3. verify -------------------------------------------------------- #
    step(3, total_steps, "Verify integrity")
    problems = verify(session, tmp, set(stats["changed_lines"]),
                      use_placeholder=args.placeholder)
    if problems:
        fail(f"{len(problems)} integrity problem(s) -- aborting, nothing was replaced")
        for p in problems[:10]:
            note(p)
        if len(problems) > 10:
            note(f"... and {len(problems) - 10} more")
        tmp.unlink(missing_ok=True)
        print()
        return 1
    ok("Line count, uuid chain, roles and non-thinking blocks all intact")
    print()

    if args.dry_run:
        note(f"Dry run -- preview left at {C.BOLD}{tmp.name}{C.RESET}")
        note("Delete it, or re-run without --dry-run to apply for real.")
        print()
        return 0

    # ---- guard: is this session still open in a running Claude Code? ------- #
    age = _dt.datetime.now().timestamp() - session.stat().st_mtime
    if age < LIVE_SESSION_SECONDS and not args.yes:
        warn(f"This transcript was written to {human_age(session.stat().st_mtime)}.")
        note("If Claude Code still has it open, it will keep appending and may")
        note("overwrite the repair. Close that session first.")
        print()
        try:
            reply = input(f"  {C.BOLD}Continue anyway? [y/N] {C.RESET}").strip().lower()
        except EOFError:
            reply = ""
        if reply not in ("y", "yes"):
            tmp.unlink(missing_ok=True)
            print()
            note("Aborted. Nothing changed.")
            print()
            return 1
        print()

    # ---- 4. back up ------------------------------------------------------- #
    step(4, total_steps, "Back up original")
    bak = backup_path(session)
    shutil.copy2(session, bak)
    ok(f"Saved {C.BOLD}{bak.name}{C.RESET} ({human_size(bak.stat().st_size)})")
    pruned = prune_backups(session, args.keep_backups)
    if pruned:
        note(f"pruned {len(pruned)} older backup(s), keeping {args.keep_backups}")
    print()

    # ---- 5. swap in ------------------------------------------------------- #
    step(5, total_steps, "Apply")
    try:
        os.replace(tmp, session)
    except OSError as exc:
        fail(f"Could not replace the transcript: {exc}")
        note("Is Claude Code still running with this session open? Exit it and retry.")
        note(f"Your original is safe at {bak.name}")
        print()
        return 1
    ok(f"Session {C.BOLD}{session.stem}{C.RESET} repaired")
    print()

    line = _s("\u2500", "-") * 66
    print(f"{C.GREEN}{line}{C.RESET}")
    print(f"  {C.BOLD}{C.GREEN}Done.{C.RESET} Resume it with:")
    print(f"    {C.BOLD}claude --resume {session.stem}{C.RESET}")
    print()
    print(f"  {C.GREY}If anything looks wrong, roll back instantly:{C.RESET}")
    print(f"    {C.BOLD}fix-claude --restore{C.RESET}")
    print(f"{C.GREEN}{line}{C.RESET}")
    print()
    return 0


# --------------------------------------------------------------------------- #
# entry point
# --------------------------------------------------------------------------- #

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="fix-claude",
        description="Repair a Claude Code session hitting "
                    "'reasoning encrypted_content was not issued to this caller'.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "examples:\n"
            "  fix-claude                    fix the newest session here\n"
            "  fix-claude --list             show sessions, change nothing\n"
            "  fix-claude --dry-run          preview the repair\n"
            "  fix-claude --session 77a46c27-6842-470e-902e-eb96d12cb7ae\n"
            "  fix-claude --dir \"D:\\Claude code\\curriculum-system\"\n"
            "  fix-claude --restore          undo the last repair\n"
        ),
    )
    p.add_argument("--dir", metavar="PATH", default=None,
                   help="project directory (default: current directory)")
    p.add_argument("--session", metavar="ID", default=None,
                   help="session UUID or filename (default: most recent)")
    p.add_argument("--list", action="store_true",
                   help="list sessions for this project and exit")
    p.add_argument("--dry-run", action="store_true",
                   help="analyse and write a .tmp preview, but do not replace anything")
    p.add_argument("--all", action="store_true",
                   help="also strip the latest assistant turn (see warning in output)")
    p.add_argument("--placeholder", action="store_true",
                   help="give thinking-only turns a placeholder text block instead "
                        "of leaving content empty (try this if empty content is "
                        "rejected)")
    p.add_argument("--restore", action="store_true",
                   help="restore the session from its newest backup")
    p.add_argument("--keep-backups", type=int, default=5, metavar="N",
                   help="how many backups to retain (default: 5, 0 = keep all)")
    p.add_argument("--show", type=int, default=15, metavar="N",
                   help="how many thinking-block rows to print (0 = all, default: 15)")
    p.add_argument("-y", "--yes", action="store_true",
                   help="skip confirmation prompts")
    p.add_argument("--no-color", action="store_true", help="disable coloured output")
    p.add_argument("--version", action="version", version=f"fix-claude {__version__}")
    return p


def main(argv: list[str] | None = None) -> int:
    global _UNICODE_OK

    args = build_parser().parse_args(argv)

    if args.no_color or not _enable_ansi():
        C.disable()
    try:
        "\u2500".encode(sys.stdout.encoding or "utf-8")
    except (UnicodeEncodeError, LookupError):
        _UNICODE_OK = False

    banner()
    print()

    cwd = Path(args.dir).expanduser() if args.dir else Path.cwd()
    if args.dir and not cwd.exists():
        die(f"No such directory: {cwd}")

    project_dir, how = resolve_project_dir(cwd)

    if args.list:
        rc = cmd_list(project_dir, how)
        print()
        return rc

    sessions = list_sessions(project_dir)
    if not sessions:
        die(f"No session transcripts in {project_dir}")

    if args.session:
        wanted = args.session.removesuffix(".jsonl")
        match = next((p for p in sessions if p.stem == wanted), None)
        if match is None:
            partial = [p for p in sessions if p.stem.startswith(wanted)]
            if len(partial) == 1:
                match = partial[0]
            elif len(partial) > 1:
                fail(f"'{wanted}' matches {len(partial)} sessions:")
                for p in partial:
                    note(p.stem)
                print()
                return 1
        if match is None:
            fail(f"No session '{wanted}' in {project_dir.name}")
            note("List what is there with:  fix-claude --list")
            print()
            return 1
        session = match
    else:
        session = sessions[0]

    if args.restore:
        rc = cmd_restore(session, args.yes)
        return rc

    return cmd_fix(session, project_dir, how, args)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print()
        print("  aborted")
        sys.exit(130)