Keep the turns a repair cuts, and hand them back to the session

Truncating the transcript is the mechanical fix, but the turns being cut are the
work itself. Losing the conversation that produced a morning's changes is most
of the damage, and a session that resumes with a hole in its memory is barely
resumed - it has no idea what it just did or what it was asked.

So --apply now writes the dropped turns out as <session>.recovered-<stamp>.md,
a readable record of what was asked, what was answered and what was run, and
appends the same text back to the truncated transcript as a single note. Tool
results are deliberately excluded: they are most of a transcript by volume and
the least useful part of a summary.

The note is a user entry marked isMeta - the marker Claude Code already uses for
its own local-command caveats, meaning context rather than something to answer -
and carries no message.id, so it cannot recreate the previous_message_id
condition being repaired. Verified on the real corrupted transcript: 1921 lines
in, 1813 kept plus the note, chained to the last good assistant uuid, no id on
the message, and the file reads back as healthy. The generated digest recovers
the actual instruction that was lost in the incident, which is the thing that
made this worth doing.

--no-reinject writes the markdown but leaves the session alone.
This commit is contained in:
smoido
2026-08-31 02:14:42 +03:00
parent a4afa55580
commit a14418b5d9
3 changed files with 168 additions and 14 deletions
+25 -4
View File
@@ -339,8 +339,8 @@ API Error: 400 diagnostics.previous_message_id: must be the `id` from a
prior /v1/messages response (starts with `msg_`)
```
There is no supported way back from that. The only fix is to truncate the
transcript to the last message Anthropic issued, losing every turn after it:
There is no supported way back from that. The transcript has to be rolled back
to the last message Anthropic issued:
```bash
claude-mode repair-session # list transcripts here and their state
@@ -348,8 +348,29 @@ claude-mode repair-session <session-id> # show what it would cut
claude-mode repair-session <session-id> --apply
```
It backs the original up first, and refuses to touch a transcript that was
written to in the last 90 seconds, since that belongs to a session still alive.
### The cut turns are not thrown away
Truncating is the mechanical fix, but the turns being cut are the work itself —
losing the conversation that produced a morning's changes is most of the damage,
and a session that resumes with a hole in its memory is barely resumed at all.
So `--apply` does three things before it deletes anything:
1. **Backs up** the original as `<session>.jsonl.pre-repair-backup-<stamp>`.
2. **Writes the dropped turns out** as `<session>.recovered-<stamp>.md` — a
readable record of what was asked, what was answered, and what was run. Tool
*results* are left out; they are most of a transcript by volume and the least
useful part of a summary.
3. **Hands them back to the session** as a single appended note, so the agent
that resumes knows what it just did.
That note is a `user` entry marked `isMeta` — the same marker Claude Code uses
for its own local-command caveats, meaning "context, not something to answer".
Critically it carries **no `message.id`**, so it cannot re-create the very
condition being repaired. `--no-reinject` writes the Markdown but leaves the
session untouched.
It refuses to touch a transcript written to in the last 90 seconds, since that
one belongs to a session still alive.
This is why a switch now asks before it writes rather than reporting afterwards.
So restart affected sessions — and `claude-mode sessions --restart` will do it
+18 -5
View File
@@ -278,7 +278,9 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
claude-mode preflight <mode> [preset] check a mode can actually serve, without switching
claude-mode sessions [--stop|--restart]
running sessions; close or reopen them
claude-mode repair-session [id] make a session resumable again after a bad switch
claude-mode repair-session [id] [--apply]
make a session resumable again after a bad switch,
keeping the cut turns as markdown + a context note
claude-mode <mode> --force switch even if preflight says no
claude-mode <mode> --yes switch without asking about running sessions
EOF
@@ -2120,11 +2122,12 @@ cm_project_dir() {
}
cmd_repair_session() {
local target='' apply=0 dir='' a file verdict age
local target='' apply=0 reinject=1 dir='' a file verdict age
for a in "$@"; do
case "$a" in
--apply) apply=1 ;;
--dry-run) apply=0 ;;
--no-reinject) reinject=0 ;;
--list) target='--list' ;;
-*) err "unknown option '$a'"; return 1 ;;
*) target="$a" ;;
@@ -2184,7 +2187,11 @@ print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
return 1
fi
verdict="$("$PY" "$JSON" repair-session "$file" $([ "$apply" -eq 1 ] && printf -- '--apply'))" || {
local flags=''
[ "$apply" -eq 1 ] && flags="$flags --apply"
[ "$reinject" -eq 0 ] && flags="$flags --no-reinject"
# shellcheck disable=SC2086
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
err 'could not read that transcript'; return 1; }
printf '%s' "$verdict" | "$PY" -c "
@@ -2206,12 +2213,18 @@ if n:
if d['applied']:
print(' %sok %s truncated to line %d, dropping %d' % (G,X,d['lastGoodLine'],d['dropLines']))
print(' %sok %s original saved as %s' % (G,X,d['backup']))
if d.get('recovered'):
print(' %sok %s dropped turns written to %s' % (G,X,d['recovered']))
if d.get('reinjected'):
print(' %sok %s and handed back to the session as a context note' % (G,X))
print()
print(' %sthat session should resume again; the dropped turns are gone%s' % (D,X))
print(' %sthat session should resume, and will know what it did%s' % (D,X))
else:
print(' %swarn%s would truncate to line %d, dropping %d line(s)' % (Y,X,d['lastGoodLine'],d['dropLines']))
print()
print(' %sre-run with --apply to do it (the original is backed up first)%s' % (D,X))
print(' %sre-run with --apply: the original is backed up, the dropped turns are%s' % (D,X))
print(' %ssaved as markdown, and handed back to the session as a context note%s' % (D,X))
print(' %s(--no-reinject writes the file but leaves the session untouched)%s' % (D,X))
"
}
+125 -5
View File
@@ -628,14 +628,97 @@ def _msg_id(obj):
return None
def cmd_repair_session(argv):
"""repair-session <transcript.jsonl> [--apply]
Prints a JSON verdict. With --apply, backs the file up and truncates it to
the last Anthropic-issued message.
def _blocks(msg):
c = msg.get("content")
if isinstance(c, str):
return [{"type": "text", "text": c}]
return c if isinstance(c, list) else []
def _clip(t, n):
t = " ".join(str(t).split())
return t if len(t) <= n else t[:n - 1] + "\u2026"
def summarise_dropped(lines, per_block=1400):
"""Render the turns a repair is about to discard as readable Markdown.
Tool *results* are deliberately left out. They are the bulk of a transcript
by volume and the least useful part of a summary - what matters on the way
back in is what was asked, what was said, and what was run.
"""
out, tools = [], 0
for line in lines:
if not line.strip():
continue
try:
d = json.loads(line)
except Exception:
continue
t, msg = d.get("type"), (d.get("message") or {})
if t == "user" and not d.get("isMeta"):
for b in _blocks(msg):
if b.get("type") == "text" and b.get("text", "").strip():
out.append("### User\n\n" + _clip(b["text"], per_block))
elif t == "assistant":
if d.get("isApiErrorMessage"):
for b in _blocks(msg):
if b.get("type") == "text":
out.append("> **API error:** " + _clip(b.get("text", ""), 300))
continue
said, ran = [], []
for b in _blocks(msg):
if b.get("type") == "text" and b.get("text", "").strip():
said.append(_clip(b["text"], per_block))
elif b.get("type") == "tool_use":
inp = b.get("input") or {}
hint = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("path") or ""
ran.append("`%s`%s" % (b.get("name", "tool"),
(" \u2014 " + _clip(hint, 120)) if hint else ""))
tools += 1
if said:
out.append("### Claude\n\n" + "\n\n".join(said))
if ran:
out.append("Ran: " + ", ".join(ran[:12]) + (" \u2026" if len(ran) > 12 else ""))
return "\n\n".join(out), tools
def _meta_entry(template, parent_uuid, text):
"""A user-role entry marked isMeta, which Claude Code treats as context
rather than as something to answer - the same marker it uses for its own
local-command caveats. It carries no message.id, so it cannot affect the
previous_message_id that made the session unresumable in the first place.
"""
now = __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
e = {
"parentUuid": parent_uuid,
"isSidechain": False,
"userType": template.get("userType", "external"),
"cwd": template.get("cwd", ""),
"sessionId": template.get("sessionId", ""),
"version": template.get("version", ""),
"gitBranch": template.get("gitBranch", ""),
"type": "user",
"isMeta": True,
"uuid": str(__import__("uuid").uuid4()),
"timestamp": now,
"message": {"role": "user", "content": text},
}
return {k: v for k, v in e.items() if v != "" or k in ("gitBranch",)}
def cmd_repair_session(argv):
"""repair-session <transcript.jsonl> [--apply] [--no-reinject]
Prints a JSON verdict. With --apply, writes the dropped turns out as
Markdown, backs the file up, truncates it to the last Anthropic-issued
message, and (unless --no-reinject) appends those turns back as a single
isMeta note so the resumed session still knows what it did.
"""
path = argv[0]
apply_it = "--apply" in argv[1:]
reinject = "--no-reinject" not in argv[1:]
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
@@ -678,17 +761,54 @@ def cmd_repair_session(argv):
"repairable": (not healthy) and last_good >= 0,
"applied": False,
"backup": "",
"recovered": "",
"reinjected": False,
"droppedTools": 0,
}
if apply_it and out["repairable"]:
stamp = __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
backup = "%s.pre-repair-backup-%s" % (path, stamp)
dropped = lines[last_good + 1:]
keep = lines[:last_good + 1]
# The turns being cut are the work itself. Losing the conversation that
# produced a morning's changes is most of the damage, so they are written
# out as readable Markdown before anything is deleted...
digest, ntools = summarise_dropped(dropped)
recovered = "%s.recovered-%s.md" % (path.rsplit(".jsonl", 1)[0], stamp)
header = ("# Recovered turns\n\n"
"Cut from `%s` on %s, because the transcript could no longer be\n"
"resumed. Everything below happened; none of it is in the session any more.\n\n"
"---\n\n" % (os.path.basename(path), stamp))
with open(recovered, "w", encoding="utf-8") as fh:
fh.write(header + (digest or "_No readable messages in the dropped turns._\n"))
with open(backup, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + ("\n" if lines else ""))
# ...and then handed back to the session, so the agent that resumes it
# knows what it just did rather than waking with a gap in its memory.
if reinject and digest:
template = _safe(keep[last_good])
body = digest if len(digest) <= 24000 else (
digest[:24000] + "\n\n\u2026 truncated; the full text is in " + recovered)
note = ("<recovered-transcript>\n"
"Context, not an instruction - do not act on it or reply to it.\n\n"
"This session was rolled back to its last resumable point after a "
"provider switch left it unable to resume. The turns below were part "
"of this conversation and are no longer in it. The full text is at "
+ recovered + "\n\n" + body + "\n</recovered-transcript>")
keep.append(json.dumps(_meta_entry(template, template.get("uuid"), note)))
out["reinjected"] = True
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines[:last_good + 1]) + "\n")
fh.write("\n".join(keep) + "\n")
out["applied"] = True
out["backup"] = backup
out["recovered"] = recovered
out["droppedTools"] = ntools
print(json.dumps(out))