Dismiss broken sessions, and hide ones untouched for a week

A broken session that will never be repaired kept the bar's warning dot lit
forever. repair-session now takes --ignore/--unignore/--unignore-all/--ignored,
recorded in ~/.claude-mode/ignored-sessions.json, and the scan hides broken
transcripts older than --max-age days (default 7, 0 disables,
CM_IGNORE_AGE_DAYS sets it). Hidden sessions move to a separate ignored[]
list with a reason, and --all always names how many it held back.

A repair clears the session's dismissal, and entries whose transcript is gone
are pruned, so a session that breaks again is never silently hidden.

The panel gets an Ignore button beside Repair and a collapsed "hidden (N)"
section with Restore. The dot still counts broken sessions only.

Bumps to 1.10.0 and fixes the widget manifest, which still said 1.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-14 23:40:24 +03:00
co-authored by Claude Opus 5
parent f776ce099e
commit cbd3b1d8a9
7 changed files with 543 additions and 69 deletions
+190 -52
View File
@@ -14,6 +14,8 @@ Subcommands:
scaffold <provider> print a blank preset
get <file> <dotted.path> print one value
presets <dir> "name<TAB>provider<TAB>desc"
scan-sessions <projects> [max-age-days] [ignored] broken transcripts, as JSON
ignore-session <ignored> <projects> add|remove|clear|list [id]
"""
import glob
@@ -878,15 +880,181 @@ def _last_msg_id_from_tail(path, window=262144):
return None, whole
def cmd_scan_sessions(argv):
"""scan-sessions <projects-dir> - JSON list of transcripts worth repairing.
def _broken_entry(path):
"""The scan's record for one transcript if it needs repair, else None.
Only `repairable` is reported: a transcript carrying a genuine Anthropic
message with a different provider's output after it. A session that ran
entirely on a gateway, or never got a reply, is not damage.
Raises OSError when the file cannot be read, which the scan counts as
skipped rather than as healthy.
"""
last, whole = _last_msg_id_from_tail(path)
# Healthy is decided by the tail alone, and is the common case.
if last is not None and last.startswith("msg_"):
return None
if last is None and whole:
return None # no replies at all; nothing to fix
with open(path, encoding="utf-8", errors="replace") as fh:
lines = fh.read().splitlines()
last_good, foreign = -1, []
for i, line in enumerate(lines):
if not line.strip():
continue
try:
d = json.loads(line)
except Exception:
continue
mid = _msg_id(d)
if not mid:
continue
if mid.startswith("msg_"):
last_good = i
elif mid.startswith("gen-"):
foreign.append(str((d.get("message") or {}).get("model", "")))
if last_good < 0:
return None # gateway-native or reply-less
tail = [i for i in range(len(lines) - 1, last_good, -1)
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
if not tail:
return None
return {
"sessionId": os.path.basename(path)[:-6],
"project": os.path.basename(os.path.dirname(path)),
"path": path,
"lines": len(lines),
"dropLines": len(lines) - (last_good + 1),
"providers": sorted(set(f for f in foreign if f)),
"mtime": int(os.path.getmtime(path)),
}
# ---------------------------------------------------------------------------
# Dismissed sessions
#
# Some damage is never going to be repaired - a throwaway session, or one whose
# work was finished another way - and without a way to say so it holds the
# bar's warning dot on forever, which teaches you to ignore the dot. So a broken
# session can be dismissed, and one untouched for longer than a threshold is
# hidden on its own. Neither is deleted or forgotten: both move to `ignored[]`,
# with the reason, and stay one command away.
#
# Its own file because nothing else can hold authored state: state.json is
# rewritten wholesale on every switch, health.json on every `health`.
# ---------------------------------------------------------------------------
IGNORE_AGE_DAYS = 7
SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _load_ignored(path):
try:
data = load(path, {})
except (OSError, ValueError):
data = {} # a corrupt file must not break the scan
sessions = data.get("sessions") if isinstance(data, dict) else None
return sessions if isinstance(sessions, dict) else {}
def _save_ignored(path, sessions):
save(path, {"schema": 1, "sessions": sessions})
def _find_transcript(projects, sid):
hits = sorted(glob.glob(os.path.join(projects, "*", sid + ".jsonl")))
return hits[0] if hits else None
def _prune_ignored(projects, sessions):
"""Drop entries whose transcript has gone, so a session id that is later
reused - or restored from a backup - does not come back already hidden."""
gone = [sid for sid in sessions if not _find_transcript(projects, sid)]
for sid in gone:
del sessions[sid]
return len(gone)
def cmd_ignore_session(argv):
"""ignore-session <ignored.json> <projects-dir> add|remove|clear|list [id]
Prints JSON. `list` reconciles every entry against the disk: whether the
transcript still exists, and whether it is still broken at all.
"""
path, projects, action = argv[0], argv[1], argv[2]
sid = argv[3] if len(argv) > 3 else ""
if sid.endswith(".jsonl"):
sid = sid[:-6]
if action in ("add", "remove") and not SESSION_ID_RE.match(sid):
raise SystemExit("not a session id: '%s'" % sid)
sessions = _load_ignored(path)
if action == "list":
rows = []
for key in sorted(sessions, key=lambda k: str(sessions[k].get("ignoredAt", ""))):
found = _find_transcript(projects, key)
try:
still = bool(found and _broken_entry(found))
except OSError:
still = False
rows.append({"sessionId": key,
"project": sessions[key].get("project", ""),
"ignoredAt": sessions[key].get("ignoredAt", ""),
"exists": bool(found), "broken": still})
print(json.dumps({"sessions": rows, "count": len(rows)}))
return
pruned = _prune_ignored(projects, sessions)
changed = False
if action == "add":
found = _find_transcript(projects, sid)
if not found:
raise SystemExit("no transcript for session '%s'" % sid)
if sid not in sessions:
sessions[sid] = {
"project": os.path.basename(os.path.dirname(found)),
"ignoredAt": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
changed = True
elif action == "remove":
changed = sessions.pop(sid, None) is not None
elif action == "clear":
changed = bool(sessions)
sessions = {}
else:
raise SystemExit("ignore-session: unknown action '%s'" % action)
if changed or pruned:
_save_ignored(path, sessions)
print(json.dumps({"action": action, "sessionId": sid, "changed": changed,
"pruned": pruned, "count": len(sessions)}))
def cmd_scan_sessions(argv):
"""scan-sessions <projects-dir> [max-age-days] [ignored.json]
JSON list of transcripts worth repairing. Only `repairable` is reported: a
transcript carrying a genuine Anthropic message with a different provider's
output after it. A session that ran entirely on a gateway, or never got a
reply, is not damage.
Broken transcripts that were dismissed, or not written to for longer than
max-age-days (default 7; 0 turns the age rule off), are reported under
`ignored[]` with a reason instead of under `broken[]`.
"""
root = argv[0]
broken, scanned, skipped = [], 0, 0
age_arg = argv[1] if len(argv) > 1 else ""
try:
max_age = int(age_arg) if age_arg != "" else IGNORE_AGE_DAYS
except ValueError:
raise SystemExit("max age must be a whole number of days, not '%s'" % age_arg)
dismissed = _load_ignored(argv[2]) if len(argv) > 2 and argv[2] else {}
cutoff = __import__("time").time() - max_age * 86400 if max_age > 0 else None
broken, ignored, scanned, skipped = [], [], 0, 0
for proj in sorted(glob.glob(os.path.join(root, "*"))):
if not os.path.isdir(proj):
@@ -894,58 +1062,27 @@ def cmd_scan_sessions(argv):
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
scanned += 1
try:
last, whole = _last_msg_id_from_tail(path)
entry = _broken_entry(path)
except OSError:
skipped += 1
continue
# Healthy is decided by the tail alone, and is the common case.
if last is not None and last.startswith("msg_"):
if entry is None:
continue
if last is None and whole:
continue # no replies at all; nothing to fix
try:
with open(path, encoding="utf-8", errors="replace") as fh:
lines = fh.read().splitlines()
except OSError:
skipped += 1
continue
last_good, foreign = -1, []
for i, line in enumerate(lines):
if not line.strip():
continue
try:
d = json.loads(line)
except Exception:
continue
mid = _msg_id(d)
if not mid:
continue
if mid.startswith("msg_"):
last_good = i
elif mid.startswith("gen-"):
foreign.append(str((d.get("message") or {}).get("model", "")))
if last_good < 0:
continue # gateway-native or reply-less
tail = [i for i in range(len(lines) - 1, last_good, -1)
if lines[i].strip() and _msg_id(_safe(lines[i])) is not None]
if not tail:
continue
broken.append({
"sessionId": os.path.basename(path)[:-6],
"project": os.path.basename(proj),
"path": path,
"lines": len(lines),
"dropLines": len(lines) - (last_good + 1),
"providers": sorted(set(f for f in foreign if f)),
"mtime": int(os.path.getmtime(path)),
})
# Dismissed outranks stale: it is the reason a person gave.
if entry["sessionId"] in dismissed:
entry["reason"] = "dismissed"
entry["ignoredAt"] = dismissed[entry["sessionId"]].get("ignoredAt", "")
ignored.append(entry)
elif cutoff is not None and entry["mtime"] < cutoff:
entry["reason"] = "stale"
ignored.append(entry)
else:
broken.append(entry)
print(json.dumps({"scanned": scanned, "skipped": skipped,
"broken": broken, "count": len(broken)}))
"broken": broken, "count": len(broken),
"ignored": ignored, "ignoredCount": len(ignored),
"maxAgeDays": max_age}))
COMMANDS = {
"health": cmd_health,
@@ -953,6 +1090,7 @@ COMMANDS = {
"sessions-json": cmd_sessions_json,
"repair-session": cmd_repair_session,
"scan-sessions": cmd_scan_sessions,
"ignore-session": cmd_ignore_session,
"stale-models": cmd_stale_models,
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,