| #!/usr/bin/env python3 | |
| """Recover a Claude Code session stuck in a loop on this API 400 error: | |
| "thinking or redacted_thinking blocks in the latest assistant message | |
| cannot be modified. These blocks must remain as they were in the original | |
| response." | |
| Thinking blocks carry a server-issued signature that the (stateless) API | |
| re-validates on every request. When a transcript ends up with those blocks in a | |
| context the signature no longer matches (e.g. duplicated/merged turns after a | |
| rewind or compaction), every resend is rejected and the session is wedged — no | |
| prompt, /compact, or rewind gets you out. | |
| This script fixes the transcript on disk. It's the persistent equivalent of | |
| "turn thinking off": it removes every thinking/ redacted_thinking block, so | |
| there are no signatures left to validate. You keep ALL text, tool calls, and | |
| tool results — you only lose the model's prior private reasoning, so the next | |
| turn reasons fresh. Empty assistant entries are dropped and the parentUuid chain | |
| is repaired so /rewind still works. Every other line is passed through | |
| byte-for-byte. Pure Python 3 stdlib — no pip installs. | |
| NOTE: this is a client-side stopgap. The underlying cause appears to be | |
| server-side (a wave of these started 2026-05-28; see claude-code issue #10199). | |
| It recovers a stuck session but does not prevent recurrence. | |
| ---------------------------------------------------------------------------- | |
| USAGE | |
| ---------------------------------------------------------------------------- | |
| 1. Find your session file. It lives at | |
| ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl | |
| The session id is shown in the Claude Code footer, or just grab the newest: | |
| ls -t ~/.claude/projects//.jsonl | head | |
| 2. QUIT Claude Code for that session first. If it stays open, it will | |
| overwrite this fix with its in-memory copy when it exits. | |
| 3. Run one of: | |
| python3 claude-session-recover SESSION.jsonl --check # report only, writes nothing | |
| python3 claude-session-recover SESSION.jsonl # writes SESSION.jsonl.fixed to inspect | |
| python3 claude-session-recover SESSION.jsonl --in-place # rewrites it (auto-backup to .bak-<ts>) | |
| 4. Resume: cd <the session's project dir> && claude --resume <session-id> | |
| To undo an --in-place run, copy the .bak-<timestamp> file back over the original. | |
| """ | | | import json | | | import os | | | import sys | | | import time | |
| THINK = {"thinking", "redacted_thinking"} | |
| def process(lines): | |
| # Parse each raw line, preserving the original text for pass-through. | | | parsed = [] | | | for raw in lines: | | | s = raw.strip() | | | if not s: | | | parsed.append((raw, None)) | | | continue | | | try: | | | parsed.append((raw, json.loads(s))) | | | except json.JSONDecodeError: | |
| parsed.append((raw, None)) | |
| dropped_uuid_to_parent = {} | |
| stripped_blocks = 0 | | | dropped_entries = 0 | | | staged = [] # [keep, raw, obj, modified] | | | for raw, obj in parsed: | |
| if obj is None or obj.get("type") != "assistant": | |
| staged.append([True, raw, obj, False]) | |
| continue | |
| msg = obj.get("message") or {} | |
| content = msg.get("content") | |
| if not isinstance(content, list): | |
| staged.append([True, raw, obj, False]) | |
| continue | |
| kept = [b for b in content | |
| if not (isinstance(b, dict) and b.get("type") in THINK)] | |
| removed = len(content) - len(kept) | |
| if removed == 0: | |
| staged.append([True, raw, obj, False]) | |
| continue | | | stripped_blocks += removed | | | if not kept: | | | dropped_uuid_to_parent[obj.get("uuid")] = obj.get("parentUuid") | | | dropped_entries += 1 | | | staged.append([False, raw, obj, True]) | | | else: | |
| msg["content"] = kept | |
| staged.append([True, raw, obj, True]) | |
| def resolve(p): | |
| seen = set() | |
| while p in dropped_uuid_to_parent and p not in seen: | |
| seen.add(p) | |
| p = dropped_uuid_to_parent[p] | |
| return p | | | out = [] | | | relinked = 0 | | | for keep, raw, obj, modified in staged: | | | if not keep: | | | continue | | | if obj is not None and obj.get("parentUuid") in dropped_uuid_to_parent: | | | obj["parentUuid"] = resolve(obj.get("parentUuid")) | | | relinked += 1 | | | modified = True | | | if modified: | | | out.append(json.dumps(obj, ensure_ascii=False) + "\n") | | | else: | | | out.append(raw if raw.endswith("\n") else raw + "\n") | | | return out, { | | | "stripped_blocks": stripped_blocks, | | | "dropped_empty_entries": dropped_entries, | | | "relinked_parents": relinked, | | | } | |
| def main(argv): | |
| if len(argv) < 2: | |
| print(__doc__) | |
| return 2 | |
| path = argv[1] | |
| in_place = "--in-place" in argv[2:] | |
| check = "--check" in argv[2:] | |
| with open(path, "r", encoding="utf-8") as f: | |
| lines = f.readlines() | |
| out, stats = process(lines) | |
| print(f"input lines : {len(lines)}") | |
| print(f"output lines: {len(out)}") | |
| print(f"thinking/redacted blocks stripped : {stats['stripped_blocks']}") | |
| print(f"empty assistant entries dropped : {stats['dropped_empty_entries']}") | |
| print(f"parentUuid links repaired : {stats['relinked_parents']}") | |
| if check: | | | print("[--check] no files written") | | | return 0 | | | if in_place: | |
| src_mode = os.stat(path).st_mode & 0o777 | |
| backup = f"{path}.bak-{int(time.time())}" | |
| with open(backup, "w", encoding="utf-8") as f: | |
| f.writelines(lines) | |
| os.chmod(backup, src_mode) | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.writelines(out) | |
| print(f"backup written: {backup}") | |
| print(f"rewrote in place: {path}") | |
| else: | |
| dest = f"{path}.fixed" | |
| with open(dest, "w", encoding="utf-8") as f: | |
| f.writelines(out) | |
| print(f"fixed copy written: {dest}") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv)) |