diff --git a/README.md b/README.md index f7ee738..e693135 100644 --- a/README.md +++ b/README.md @@ -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 # show what it would cut claude-mode repair-session --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 `.jsonl.pre-repair-backup-`. +2. **Writes the dropped turns out** as `.recovered-.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 diff --git a/linux/claude-mode b/linux/claude-mode index f0719a7..aec20db 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -278,7 +278,9 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode preflight [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 --force switch even if preflight says no claude-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)) " } diff --git a/linux/cm-json.py b/linux/cm-json.py index 64821cb..62c9aca 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -628,14 +628,97 @@ def _msg_id(obj): return None -def cmd_repair_session(argv): - """repair-session [--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 [--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 = ("\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") + 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))