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:
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user