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:
@@ -430,6 +430,27 @@ session id is looked up across every project** — you rarely remember which
|
||||
project a session you cannot resume belonged to. `--all` drops the scoping
|
||||
entirely.
|
||||
|
||||
Not every broken session is worth repairing — a throwaway, or one whose work
|
||||
was finished some other way — and one that can never be cleared keeps the bar's
|
||||
warning dot lit for good. So a session can be dismissed, and one untouched for
|
||||
more than 7 days is hidden on its own:
|
||||
|
||||
```bash
|
||||
claude-mode repair-session --ignore <session-id> # stop counting it
|
||||
claude-mode repair-session --ignored # what is dismissed, and whether it is still broken
|
||||
claude-mode repair-session --unignore <session-id> # or --unignore-all
|
||||
claude-mode repair-session --all --max-age 0 # include the age-hidden ones (CM_IGNORE_AGE_DAYS sets the default)
|
||||
```
|
||||
|
||||
Neither touches the transcript. Hidden sessions are always named — `--all`
|
||||
ends with a line like `2 hidden: 1 ignored, 1 older than 7 days` — so age-hiding
|
||||
never looks like damage disappearing. Dismissals live in
|
||||
`~/.claude-mode/ignored-sessions.json`; an entry is dropped when its transcript
|
||||
is deleted, and when the session is repaired, so a session that breaks again
|
||||
later is not silently hidden. In the bar panel each broken session has an
|
||||
**Ignore** button beside **Repair**, and the hidden ones sit under a collapsed
|
||||
`hidden (N)` row with **Restore**.
|
||||
|
||||
`--all` reports only what is actually actionable, which matters more than it
|
||||
sounds. Of 59 transcripts here it initially flagged 16; on inspection 5 had
|
||||
simply never received a reply, and 10 had run start-to-finish on a gateway, so
|
||||
|
||||
+118
-7
@@ -16,6 +16,7 @@ CM_BIN="$CM_ROOT/bin"
|
||||
CM_PRESETS="$CM_ROOT/presets"
|
||||
CM_BACKUPS="$CM_ROOT/backups"
|
||||
CM_STATE="$CM_ROOT/state.json"
|
||||
CM_IGNORED="$CM_ROOT/ignored-sessions.json"
|
||||
CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||
CM_SETTINGS="$CM_SETTINGS_DIR/settings.json"
|
||||
CM_HELPER="$CM_BIN/claude-key-helper.sh"
|
||||
@@ -281,6 +282,11 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio
|
||||
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 repair-session --ignore <id> | --unignore <id> | --unignore-all | --ignored
|
||||
stop (or resume) counting one broken session
|
||||
claude-mode repair-session --all [--max-age <days>]
|
||||
broken sessions untouched for longer are hidden
|
||||
(default 7; 0 shows them all)
|
||||
claude-mode <mode> --force switch even if preflight says no
|
||||
claude-mode <mode> --yes switch without asking about running sessions
|
||||
EOF
|
||||
@@ -2256,9 +2262,59 @@ cm_file_mtime() {
|
||||
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
# Dismissing is bookkeeping, not repair: the transcript is left exactly as it
|
||||
# is, and only whether the scan - and so the bar's warning dot - counts it
|
||||
# changes. Hence no confirmation, and one flag to undo it.
|
||||
cm_ignore_session() {
|
||||
local act="$1" id="${2:-}" out op pruned projects="$CM_SETTINGS_DIR/projects"
|
||||
case "$act" in
|
||||
list)
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" list 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
head_ 'dismissed sessions'
|
||||
printf '%s' "$out" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
D,X='\033[90m','\033[0m'
|
||||
if not d['sessions']:
|
||||
print(' none')
|
||||
for s in d['sessions']:
|
||||
state = 'still broken' if s['broken'] else ('transcript gone' if not s['exists'] else 'no longer broken')
|
||||
print(' %-38s %s' % (s['sessionId'], s['project']))
|
||||
print(' %signored %s, %s%s' % (D, s['ignoredAt'][:10], state, X))
|
||||
print()
|
||||
print(' %sclaude-mode repair-session --unignore <id> (or --unignore-all)%s' % (D,X))
|
||||
"
|
||||
return 0 ;;
|
||||
ignore|unignore)
|
||||
[ -n "$id" ] || { err "--$act needs a session id"; return 1; }
|
||||
[ "$act" = ignore ] && op=add || op=remove ;;
|
||||
unignore-all)
|
||||
op=clear ;;
|
||||
esac
|
||||
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" "$op" "${id%.jsonl}" 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
id="${id%.jsonl}"
|
||||
case "$act:$out" in
|
||||
ignore:*'"changed": true'*) ok "hidden $id"
|
||||
say "${C_DIM}claude-mode repair-session --unignore $id brings it back${C_RESET}" ;;
|
||||
ignore:*) ok "$id was already hidden" ;;
|
||||
unignore:*'"changed": true'*) ok "restored $id" ;;
|
||||
unignore:*) ok "$id was not hidden" ;;
|
||||
*'"changed": true'*) ok 'restored every dismissed session' ;;
|
||||
*) ok 'nothing was dismissed' ;;
|
||||
esac
|
||||
pruned="$(printf '%s' "$out" | sed -n 's/.*"pruned": \([0-9]*\).*/\1/p')"
|
||||
[ "${pruned:-0}" -gt 0 ] && say "${C_DIM}(forgot $pruned whose transcript no longer exists)${C_RESET}"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_repair_session() {
|
||||
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
|
||||
for a in "$@"; do
|
||||
local ignore_act='' max_age="${CM_IGNORE_AGE_DAYS:-}"
|
||||
while [ $# -gt 0 ]; do
|
||||
a="$1"; shift
|
||||
case "$a" in
|
||||
--apply) apply=1 ;;
|
||||
--dry-run) apply=0 ;;
|
||||
@@ -2266,18 +2322,37 @@ cmd_repair_session() {
|
||||
--list) target='--list' ;;
|
||||
--all) scan_all=1 ;;
|
||||
--json) as_json=1; scan_all=1 ;;
|
||||
# The id may follow the flag or stand anywhere as the positional, so
|
||||
# `--ignore <id>` and `<id> --ignore` both do what they say.
|
||||
--ignore|--unignore)
|
||||
ignore_act="${a#--}"
|
||||
if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then target="$1"; shift; fi ;;
|
||||
--unignore-all) ignore_act='unignore-all' ;;
|
||||
--ignored) ignore_act='list' ;;
|
||||
--max-age)
|
||||
[ $# -gt 0 ] || { err '--max-age needs a number of days'; return 1; }
|
||||
max_age="$1"; shift ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) target="$a" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$max_age" ] && ! [[ "$max_age" =~ ^[0-9]+$ ]]; then
|
||||
err "max age is a whole number of days, not '$max_age' (--max-age / CM_IGNORE_AGE_DAYS)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$ignore_act" ]; then
|
||||
cm_ignore_session "$ignore_act" "$target"
|
||||
return $?
|
||||
fi
|
||||
|
||||
# A session you need to repair is one you could not resume, which is a poor
|
||||
# position from which to remember which project it belonged to. --all drops
|
||||
# the working-directory scoping and reports only what is actually broken.
|
||||
if [ "$scan_all" -eq 1 ]; then
|
||||
local scan
|
||||
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" 2>/dev/null)" || {
|
||||
err 'could not scan transcripts'; return 1; }
|
||||
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>&1)" || {
|
||||
err "could not scan transcripts: $scan"; return 1; }
|
||||
|
||||
if [ "$as_json" -eq 1 ]; then
|
||||
printf '%s\n' "$scan"
|
||||
@@ -2294,11 +2369,24 @@ for b in d['broken']:
|
||||
prov = ', '.join(b['providers']) or 'another provider'
|
||||
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
|
||||
print()
|
||||
ig = d.get('ignored') or []
|
||||
if not d['broken']:
|
||||
print(' %sok %s nothing to repair across %d transcript(s)' % (G,X,d['scanned']))
|
||||
print(' %sok %s nothing to repair across %d transcript(s)%s' % (G,X,d['scanned'],
|
||||
' that is not hidden' if ig else ''))
|
||||
else:
|
||||
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
|
||||
print(' %sclaude-mode repair-session <id> --apply%s' % (D,X))
|
||||
print(' %sclaude-mode repair-session <id> --apply (or --ignore <id> to stop counting it)%s' % (D,X))
|
||||
# Age-hiding must never look like damage vanishing, so whatever was held back
|
||||
# is always named, with the way to see it.
|
||||
if ig:
|
||||
nd = sum(1 for i in ig if i.get('reason') == 'dismissed')
|
||||
ns = len(ig) - nd
|
||||
bits, hints = [], []
|
||||
if nd:
|
||||
bits.append('%d ignored' % nd); hints.append('--ignored lists them')
|
||||
if ns:
|
||||
bits.append('%d older than %d days' % (ns, d.get('maxAgeDays', 0))); hints.append('--max-age 0 shows all')
|
||||
print(' %s%d hidden: %s (%s)%s' % (D, len(ig), ', '.join(bits), '; '.join(hints), X))
|
||||
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
|
||||
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
"
|
||||
@@ -2332,15 +2420,30 @@ print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
|
||||
if [ -z "$target" ] || [ "$target" = "--list" ]; then
|
||||
head_ 'session transcripts here'
|
||||
local f v
|
||||
# This listing classifies each file itself rather than going through
|
||||
# the scan, so it asks the scan which ones are hidden. It still shows
|
||||
# them - listing everything here is its job - but says why the bar
|
||||
# is not counting them.
|
||||
local f v hidden reason
|
||||
hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \
|
||||
| "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
for i in d.get('ignored') or []:
|
||||
print('%s\t%s' % (i['sessionId'], 'ignored' if i.get('reason') == 'dismissed'
|
||||
else 'older than %d days' % d.get('maxAgeDays', 0)))
|
||||
" 2>/dev/null)"
|
||||
for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do
|
||||
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
|
||||
reason="$(printf '%s\n' "$hidden" | awk -F'\t' -v s="$(basename "$f" .jsonl)" '$1 == s { print $2; exit }')"
|
||||
printf '%s' "$v" | "$PY" -c "
|
||||
import json,sys,os
|
||||
d=json.load(sys.stdin)
|
||||
state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found')
|
||||
if sys.argv[1]:
|
||||
state += ' (hidden: %s)' % sys.argv[1]
|
||||
print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||
"
|
||||
" "$reason"
|
||||
done
|
||||
printf '\n %sclaude-mode repair-session <session-id> --apply%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET"
|
||||
@@ -2366,6 +2469,14 @@ print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
|
||||
err 'could not read that transcript'; return 1; }
|
||||
|
||||
# A repaired session is no longer damage. Left dismissed, it would stay
|
||||
# hidden if the same session broke again later.
|
||||
case "$verdict" in
|
||||
*'"applied": true'*)
|
||||
"$PY" "$JSON" ignore-session "$CM_IGNORED" "$CM_SETTINGS_DIR/projects" \
|
||||
remove "${target%.jsonl}" >/dev/null 2>&1 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$verdict" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
|
||||
+167
-29
@@ -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,38 +880,21 @@ 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.
|
||||
"""
|
||||
root = argv[0]
|
||||
broken, scanned, skipped = [], 0, 0
|
||||
|
||||
for proj in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
if not os.path.isdir(proj):
|
||||
continue
|
||||
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
|
||||
scanned += 1
|
||||
try:
|
||||
last, whole = _last_msg_id_from_tail(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_"):
|
||||
continue
|
||||
return None
|
||||
if last is None and whole:
|
||||
continue # no replies at all; nothing to fix
|
||||
return None # 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):
|
||||
@@ -928,24 +913,176 @@ def cmd_scan_sessions(argv):
|
||||
foreign.append(str((d.get("message") or {}).get("model", "")))
|
||||
|
||||
if last_good < 0:
|
||||
continue # gateway-native or reply-less
|
||||
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:
|
||||
continue
|
||||
return None
|
||||
|
||||
broken.append({
|
||||
return {
|
||||
"sessionId": os.path.basename(path)[:-6],
|
||||
"project": os.path.basename(proj),
|
||||
"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]
|
||||
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):
|
||||
continue
|
||||
for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))):
|
||||
scanned += 1
|
||||
try:
|
||||
entry = _broken_entry(path)
|
||||
except OSError:
|
||||
skipped += 1
|
||||
continue
|
||||
if entry is None:
|
||||
continue
|
||||
# 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,
|
||||
|
||||
@@ -132,6 +132,12 @@ BarWidget {
|
||||
// to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive
|
||||
// version that read every byte of every one.
|
||||
property var brokenSessions: []
|
||||
// Dismissed, or broken but untouched for longer than the age limit. Kept
|
||||
// out of the dot on purpose: hiding one is how you tell the bar to stop
|
||||
// asking about it.
|
||||
property var ignoredSessions: []
|
||||
property int ignoreAgeDays: 7
|
||||
property bool rescanPending: false
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
@@ -143,11 +149,24 @@ BarWidget {
|
||||
var d = null
|
||||
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
|
||||
root.brokenSessions = (d && d.broken) ? d.broken : []
|
||||
root.ignoredSessions = (d && d.ignored) ? d.ignored : []
|
||||
if (d && d.maxAgeDays !== undefined) root.ignoreAgeDays = Number(d.maxAgeDays)
|
||||
}
|
||||
}
|
||||
// A scan already under way was started before whatever asked for this
|
||||
// one, so its answer may predate the change - run once more after it.
|
||||
onExited: function (code) {
|
||||
if (root.rescanPending) {
|
||||
root.rescanPending = false
|
||||
Qt.callLater(root.scanSessions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSessions() { if (!scanProc.running) scanProc.running = true }
|
||||
function scanSessions() {
|
||||
if (scanProc.running) root.rescanPending = true
|
||||
else scanProc.running = true
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 20 * 60 * 1000
|
||||
|
||||
@@ -32,6 +32,7 @@ Panel {
|
||||
function open() {
|
||||
root.expandedMode = ""
|
||||
root.lastError = ""
|
||||
root.hiddenExpanded = false
|
||||
root.resetFlow()
|
||||
if (widget) widget.refresh()
|
||||
root.controller.show()
|
||||
@@ -183,6 +184,47 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hidden transcripts
|
||||
//
|
||||
// Ignoring only changes whether the scan counts a session - the transcript is
|
||||
// not touched and Restore is one click away - so unlike Repair it needs no
|
||||
// confirmation. Age-hidden ones are never called ignored: nobody chose that.
|
||||
readonly property var ignored: widget && widget.ignoredSessions ? widget.ignoredSessions : []
|
||||
readonly property var dismissedList: ignored.filter(function (e) { return String(e.reason) === "dismissed" })
|
||||
readonly property var staleList: ignored.filter(function (e) { return String(e.reason) !== "dismissed" })
|
||||
readonly property int ageDays: widget ? widget.ignoreAgeDays : 7
|
||||
property bool hiddenExpanded: false
|
||||
// The card clamps rather than scrolls, so a long list would be cut off at
|
||||
// the bottom edge instead of becoming reachable. The CLI has the full list.
|
||||
readonly property int hiddenCap: 4
|
||||
|
||||
function projectLabel(p) { return String(p).replace(/^-/, "").replace(/-/g, "/") }
|
||||
|
||||
function setIgnored(entry, on) {
|
||||
if (root.busy || !entry) return
|
||||
root.busy = true
|
||||
root.lastError = ""
|
||||
ignoreProc.command = root.cli(["repair-session", on ? "--ignore" : "--unignore", String(entry.sessionId)])
|
||||
ignoreProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ignoreProc
|
||||
running: false
|
||||
stderr: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
|
||||
var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" })
|
||||
if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim()
|
||||
}
|
||||
}
|
||||
onExited: function (code) {
|
||||
root.busy = false
|
||||
if (widget) widget.scanSessions()
|
||||
}
|
||||
}
|
||||
|
||||
function storeServerKey() {
|
||||
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
|
||||
remedyProc.running = true
|
||||
@@ -1014,13 +1056,14 @@ Panel {
|
||||
// point is to be noticed without going looking.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.stage === "list" && root.broken.length > 0
|
||||
visible: root.stage === "list" && (root.broken.length > 0 || root.ignored.length > 0)
|
||||
spacing: Style.space(4)
|
||||
|
||||
PanelSeparator { width: parent.width }
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.broken.length > 0
|
||||
text: root.broken.length === 1
|
||||
? "1 session cannot be resumed"
|
||||
: root.broken.length + " sessions cannot be resumed"
|
||||
@@ -1035,21 +1078,22 @@ Panel {
|
||||
model: root.stage === "list" ? root.broken : []
|
||||
|
||||
Item {
|
||||
id: brokenRow
|
||||
required property var modelData
|
||||
width: column.width
|
||||
height: Style.space(26)
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: repairBtn.left
|
||||
anchors.right: ignoreBtn.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(parent.parent.modelData.sessionId).substring(0, 8)
|
||||
+ " · " + String(parent.parent.modelData.project).replace(/^-/, "").replace(/-/g, "/")
|
||||
text: String(brokenRow.modelData.sessionId).substring(0, 8)
|
||||
+ " · " + root.projectLabel(brokenRow.modelData.project)
|
||||
color: Color.popups.text
|
||||
elide: Text.ElideMiddle
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
@@ -1058,7 +1102,7 @@ Panel {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: parent.parent.modelData.dropLines + " turn lines after the last good message"
|
||||
text: brokenRow.modelData.dropLines + " turn lines after the last good message"
|
||||
color: Color.muted
|
||||
elide: Text.ElideRight
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
@@ -1066,15 +1110,156 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
PillButton {
|
||||
id: ignoreBtn
|
||||
anchors.right: repairBtn.left
|
||||
anchors.rightMargin: Style.space(5)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Ignore"
|
||||
onTriggered: root.setIgnored(brokenRow.modelData, true)
|
||||
}
|
||||
|
||||
PillButton {
|
||||
id: repairBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Repair"
|
||||
onTriggered: root.askRepair(parent.modelData)
|
||||
onTriggered: root.askRepair(brokenRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hidden: dismissed here, or broken but untouched for longer than
|
||||
// the age limit. Collapsed, because the point of hiding them was that
|
||||
// they stop taking up attention - but never gone without a trace.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.space(20)
|
||||
visible: root.ignored.length > 0
|
||||
|
||||
Text {
|
||||
id: hiddenLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "hidden (" + root.ignored.length + ")"
|
||||
color: hiddenArea.containsMouse ? Color.popups.text : Color.muted
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: hiddenLabel.right
|
||||
anchors.leftMargin: Style.space(4)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String.fromCodePoint(root.hiddenExpanded ? 0xF0140 : 0xF0142)
|
||||
color: hiddenLabel.color
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(11)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hiddenArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.hiddenExpanded = !root.hiddenExpanded
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.hiddenExpanded && root.ignored.length > 0
|
||||
spacing: Style.space(3)
|
||||
|
||||
Text {
|
||||
visible: root.dismissedList.length > 0
|
||||
text: "Ignored (" + root.dismissedList.length + ")"
|
||||
color: Color.popups.text
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(10)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.hiddenExpanded ? root.dismissedList.slice(0, root.hiddenCap) : []
|
||||
|
||||
Item {
|
||||
id: dismissedRow
|
||||
required property var modelData
|
||||
width: column.width
|
||||
height: Style.space(26)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: restoreBtn.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(dismissedRow.modelData.sessionId).substring(0, 8)
|
||||
+ " · " + root.projectLabel(dismissedRow.modelData.project)
|
||||
color: Color.muted
|
||||
elide: Text.ElideMiddle
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
|
||||
PillButton {
|
||||
id: restoreBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Restore"
|
||||
onTriggered: root.setIgnored(dismissedRow.modelData, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.dismissedList.length > root.hiddenCap
|
||||
text: "… and " + (root.dismissedList.length - root.hiddenCap)
|
||||
+ " more · claude-mode repair-session --ignored"
|
||||
color: Color.muted
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.staleList.length > 0
|
||||
text: "Older than " + root.ageDays + " days (" + root.staleList.length + ")"
|
||||
color: Color.popups.text
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(10)
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.hiddenExpanded ? root.staleList.slice(0, root.hiddenCap) : []
|
||||
|
||||
Text {
|
||||
required property var modelData
|
||||
width: column.width
|
||||
text: String(modelData.sessionId).substring(0, 8)
|
||||
+ " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d"
|
||||
+ " · " + root.projectLabel(modelData.project)
|
||||
color: Color.muted
|
||||
elide: Text.ElideMiddle
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(10)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.staleList.length > 0
|
||||
text: (root.staleList.length > root.hiddenCap
|
||||
? "… and " + (root.staleList.length - root.hiddenCap) + " more. " : "")
|
||||
+ "Not counted because nothing has touched them since. "
|
||||
+ "claude-mode repair-session --all --max-age 0 lists them."
|
||||
color: Color.muted
|
||||
opacity: 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.space(9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Confirming one repair.
|
||||
@@ -1193,7 +1378,7 @@ Panel {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.busy
|
||||
? "switching…"
|
||||
? (ignoreProc.running ? "updating…" : (repairProc.running ? "repairing…" : "switching…"))
|
||||
: (root.known && root.stage === "list" ? "Restart claude to pick this up." : "")
|
||||
visible: text !== ""
|
||||
color: Color.muted
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "smoido.claude-mode",
|
||||
"name": "Claude Mode",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"author": "smoido",
|
||||
"description": "Which provider Claude Code is pointed at, and a one-click switch between them",
|
||||
"kinds": ["bar-widget"],
|
||||
|
||||
Reference in New Issue
Block a user