Skip to content

Instantly share code, notes, and snippets.

@robertfw
Created May 28, 2026 22:28
Show Gist options
  • Select an option

  • Save robertfw/993dbe8643c4fbdf12005dff2eca1f90 to your computer and use it in GitHub Desktop.

Select an option

Save robertfw/993dbe8643c4fbdf12005dff2eca1f90 to your computer and use it in GitHub Desktop.
#!/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))
@robertfw

Copy link
Copy Markdown
Author

Note that the bug manifests outside of what is described above - it'll happen outside of rewind/compact/resume. But the script does seem to unblock sessions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment