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:
+190
-52
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user