cd /news/ai-tools/claude-session-recover · home topics ai-tools article
[ARTICLE · art-17055] src=gist.github.com pub= topic=ai-tools verified=true sentiment=· neutral

claude-session-recover

A developer created a Python script called `claude-session-recover` to fix Claude Code sessions stuck in an unrecoverable loop caused by a server-side API validation error. The tool removes `thinking` and `redacted_thinking` blocks from session transcripts on disk, eliminating the signature mismatch that prevents the stateless API from accepting further requests. The fix preserves all text, tool calls, and tool results while repairing the parentUuid chain to maintain rewind functionality, though the developer notes this is a client-side stopgap for a server-side issue that began appearing around May 28, 2026.

read5 min publishedMay 28, 2026

| #!/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)) |
── more in #ai-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-session-recov…] indexed:0 read:5min 2026-05-28 ·