Notice broken sessions in the bar, and offer to repair them there

Nothing tells you a session is unresumable until you try to resume it, and by
then you have usually forgotten which one it was. The widget now scans every
project on a timer and whenever the panel opens, marks its icon when something
needs fixing, and lists the affected sessions with a repair button that says
what it will drop and what it will keep before doing anything.

The scan had to get roughly eighty times cheaper first. Classifying a transcript
needs two facts - what its last message id is, and whether any Anthropic id
exists at all - and the first settles the common case alone. Reading the tail of
each file and only opening the whole thing when the tail already looks wrong
takes the sweep from ~5s to ~60ms across 59 transcripts, which is the difference
between something that can sit on a timer and something that cannot. `--all`
uses the same path, and `--json` exposes it.

The badge is a dot beside the mark rather than a recolouring of it: this widget's
job is to report which provider is active, and tinting it red to mean something
else entirely would be a lie about that.

Verified by planting a genuinely corrupted transcript, watching the scan find it
and the dot appear, then removing it and watching both clear.
This commit is contained in:
smoido
2026-08-31 02:28:24 +03:00
parent 43cef6bf9e
commit 4c36f4d00b
5 changed files with 393 additions and 32 deletions
+17
View File
@@ -388,6 +388,23 @@ session untouched.
It refuses to touch a transcript written to in the last 90 seconds, since that
one belongs to a session still alive.
### The bar notices for you
Nothing tells you a session is unresumable until you try to resume it, by which
point you have usually forgotten which one it was. So the widget scans every
project on a timer (and whenever the panel opens) and puts a dot on its icon
when there is something to fix. Clicking through lists the affected sessions and
offers to repair each one, after saying what it will drop and what it will keep.
The dot is a dot rather than a colour change, because recolouring the mark would
misreport the active mode — which is the widget's actual job.
That scan is only affordable because it reads the *tail* of each transcript
first: if the last message is Anthropic's, the transcript is healthy and the
rest of the file is never opened. Since that is the overwhelmingly common case,
the whole sweep costs ~60ms for 59 transcripts, against ~5s for the obvious
version that reads every byte of every one.
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
for you:
+28 -26
View File
@@ -2122,7 +2122,7 @@ cm_project_dir() {
}
cmd_repair_session() {
local target='' apply=0 reinject=1 scan_all=0 dir='' a file verdict age
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
for a in "$@"; do
case "$a" in
--apply) apply=1 ;;
@@ -2130,6 +2130,7 @@ cmd_repair_session() {
--no-reinject) reinject=0 ;;
--list) target='--list' ;;
--all) scan_all=1 ;;
--json) as_json=1; scan_all=1 ;;
-*) err "unknown option '$a'"; return 1 ;;
*) target="$a" ;;
esac
@@ -2139,32 +2140,33 @@ cmd_repair_session() {
# 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
head_ 'scanning every session transcript'
local f v bad=0 total=0
for f in "$CM_SETTINGS_DIR"/projects/*/*.jsonl; do
[ -f "$f" ] || continue
total=$((total + 1))
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
printf '%s' "$v" | "$PY" -c "
import json,sys,os
d=json.load(sys.stdin)
# Only 'repairable' is damage this tool can or should act on.
if d['kind'] != 'repairable': raise SystemExit(0)
print(' %-38s %s' % (os.path.basename(d['path'])[:-6], os.path.basename(os.path.dirname(d['path']))))
print(' would drop %d line(s); %d foreign id(s)' % (d['dropLines'], len(d['foreignIds'])))
raise SystemExit(9)
" && continue
bad=$((bad + 1))
done
printf '\n'
if [ "$bad" -eq 0 ]; then
ok "nothing to repair across $total transcript(s)"
else
warn "$bad of $total transcript(s) were cut short by a mode switch"
printf ' %sclaude-mode repair-session <id> --apply%s\n' "$C_DIM" "$C_RESET"
local scan
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" 2>/dev/null)" || {
err 'could not scan transcripts'; return 1; }
if [ "$as_json" -eq 1 ]; then
printf '%s\n' "$scan"
return 0
fi
printf ' %ssessions that ran entirely on a gateway are not listed: they carry that%s\n' "$C_DIM" "$C_RESET"
printf ' %sprovider'"'"'s ids by design and resume fine under it%s\n' "$C_DIM" "$C_RESET"
head_ 'scanning every session transcript'
printf '%s' "$scan" | "$PY" -c "
import json,sys
d=json.load(sys.stdin)
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
for b in d['broken']:
print(' %-38s %s' % (b['sessionId'], b['project']))
prov = ', '.join(b['providers']) or 'another provider'
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
print()
if not d['broken']:
print(' %sok %s nothing to repair across %d transcript(s)' % (G,X,d['scanned']))
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(' %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))
"
return 0
fi
+106
View File
@@ -16,6 +16,7 @@ Subcommands:
presets <dir> "name<TAB>provider<TAB>desc"
"""
import glob
import json
import os
import re
@@ -837,11 +838,116 @@ def _safe(line):
except Exception:
return {}
def _last_msg_id_from_tail(path, window=262144):
"""The id of the last message-bearing line, read from the end of the file.
Classifying a transcript needs two facts: what the last message id is, and
whether any Anthropic id exists at all. The first decides the common case on
its own - if the last one is `msg_`, the transcript is healthy and the rest
of the file never has to be touched. Since that is 40-odd of the 59 here,
and some are 10MB, reading the tail first is the difference between a scan
that can run on a timer and one that cannot.
Returns (id_or_None, saw_whole_file).
"""
size = os.path.getsize(path)
with open(path, "rb") as fh:
start = max(0, size - window)
fh.seek(start)
chunk = fh.read()
whole = start == 0
if not whole:
nl = chunk.find(b"\n") # drop the partial first line
chunk = chunk[nl + 1:] if nl >= 0 else b""
for raw in reversed(chunk.splitlines()):
if not raw.strip():
continue
try:
d = json.loads(raw.decode("utf-8", "replace"))
except Exception:
continue
mid = _msg_id(d)
if mid:
return mid, whole
return None, whole
def cmd_scan_sessions(argv):
"""scan-sessions <projects-dir> - 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.
"""
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
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)),
})
print(json.dumps({"scanned": scanned, "skipped": skipped,
"broken": broken, "count": len(broken)}))
COMMANDS = {
"health": cmd_health,
"preflight-json": cmd_preflight_json,
"sessions-json": cmd_sessions_json,
"repair-session": cmd_repair_session,
"scan-sessions": cmd_scan_sessions,
"stale-models": cmd_stale_models,
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,
+75 -2
View File
@@ -63,6 +63,15 @@ BarWidget {
// bar stays quiet exactly when nothing unusual is configured.
readonly property bool gateway: known && mode !== "anthropic"
readonly property color urgentColor: {
var c = Color.urgent
var mx = Math.max(c.r, c.g, c.b)
var mn = Math.min(c.r, c.g, c.b)
var l = (mx + mn) / 2
var d = 1 - Math.abs(2 * l - 1)
return (d > 0.0001 ? (mx - mn) / d : 0) >= 0.15 ? c : "#d2685f"
}
readonly property color activeColor: !known
? Color.muted
: (gateway ? Color.accent : (bar ? bar.barForeground : Color.foreground))
@@ -111,10 +120,48 @@ BarWidget {
running: false
}
// ---- Broken transcripts
//
// A session cut short by a mode switch cannot be resumed, and nothing tells
// you until you try - by which time you have usually forgotten which session
// it was. So the bar checks periodically and marks itself when there is
// something to fix.
//
// The scan reads the tail of each transcript first and only opens the whole
// file when the tail already looks wrong, which is what makes it cheap enough
// 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: []
Process {
id: scanProc
running: false
command: [root.cmRoot + "/bin/claude-mode", "repair-session", "--json"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var d = null
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
root.brokenSessions = (d && d.broken) ? d.broken : []
}
}
}
function scanSessions() { if (!scanProc.running) scanProc.running = true }
Timer {
interval: 20 * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.scanSessions()
}
function refresh() {
healthFile.reload()
stateFile.reload()
if (!root.health) seedProc.running = true
root.scanSessions()
}
Component.onCompleted: Qt.callLater(root.refresh)
@@ -134,16 +181,36 @@ BarWidget {
anchors.centerIn: parent
spacing: root.gap
Item {
anchors.verticalCenter: parent.verticalCenter
width: icon.width
height: icon.height
visible: root.logo !== ""
BrandIcon {
id: icon
anchors.verticalCenter: parent.verticalCenter
visible: root.logo !== ""
anchors.fill: parent
pathData: root.logo
opticalScale: Modes.logoScale(root.mode)
color: root.activeColor
iconSize: root.iconPx
}
// Recolouring the mark would misreport the mode, which is this widget's
// whole job, so the warning gets its own dot instead.
Rectangle {
visible: root.brokenSessions.length > 0
width: Math.max(4, Math.round(root.iconPx / 3.2))
height: width
radius: width / 2
color: root.urgentColor
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: -Math.round(width / 3)
anchors.topMargin: -Math.round(width / 4)
}
}
// Only reached when claude-mode is not installed - there is no brand to
// draw for "no answer", so the pictograph stands in.
Text {
@@ -185,6 +252,11 @@ BarWidget {
var models = root.health && root.health.models ? root.health.models : null
if (models && models.opus) lines.push("opus " + models.opus)
if (models && models.sonnet) lines.push("sonnet " + models.sonnet)
if (root.brokenSessions.length > 0) {
lines.push(root.brokenSessions.length === 1
? "1 session needs repair"
: root.brokenSessions.length + " sessions need repair")
}
return lines.join(" · ")
}
@@ -199,6 +271,7 @@ BarWidget {
}
onHealthChanged: syncTooltip()
onBrokenSessionsChanged: syncTooltip()
// Hover must come from a HoverHandler: once triggerPress() exists the bar's
// own slot MouseArea accepts hover events and swallows them before any
+163
View File
@@ -142,6 +142,47 @@ Panel {
}
}
// ---- Broken transcripts
//
// Repair truncates a transcript back to its last resumable point. That is
// destructive enough to confirm, and reversible enough to offer: the original
// is backed up, the cut turns are written out as Markdown, and the same text
// is handed back to the session so it still knows what it did.
readonly property var broken: widget && widget.brokenSessions ? widget.brokenSessions : []
property var repairTarget: null
function askRepair(entry) {
root.repairTarget = entry
root.stage = "repair"
}
function doRepair() {
if (root.busy || !root.repairTarget) return
root.busy = true
repairProc.command = root.cli(["repair-session", String(root.repairTarget.sessionId), "--apply"])
repairProc.running = true
}
Process {
id: repairProc
running: false
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
if (msg !== "") root.lastError = msg.split("\n").pop().trim()
}
}
onExited: function (code) {
root.busy = false
root.repairTarget = null
root.stage = "list"
// The scan is what drives the bar's warning dot, so re-run it rather than
// trusting this side to have guessed the new state.
if (widget) widget.scanSessions()
}
}
function storeServerKey() {
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
remedyProc.running = true
@@ -154,6 +195,7 @@ Panel {
root.pendingMode = ""
root.pendingPreset = ""
root.serverPreset = ""
root.repairTarget = null
}
function switchTo(mode, presetName) {
@@ -967,6 +1009,127 @@ Panel {
}
}
// ---- Sessions that a switch cut short. Shown in the list, because the
// point is to be noticed without going looking.
Column {
width: parent.width
visible: root.stage === "list" && root.broken.length > 0
spacing: Style.space(4)
PanelSeparator { width: parent.width }
Text {
width: parent.width
text: root.broken.length === 1
? "1 session cannot be resumed"
: root.broken.length + " sessions cannot be resumed"
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
font.bold: true
}
Repeater {
model: root.stage === "list" ? root.broken : []
Item {
required property var modelData
width: column.width
height: Style.space(26)
Column {
anchors.left: parent.left
anchors.right: repairBtn.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, "/")
color: Color.popups.text
elide: Text.ElideMiddle
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: parent.parent.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
font.pixelSize: Style.space(9)
}
}
PillButton {
id: repairBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Repair"
onTriggered: root.askRepair(parent.modelData)
}
}
}
}
// ---- Confirming one repair.
Column {
width: parent.width
visible: root.stage === "repair" && root.repairTarget !== null
spacing: Style.space(7)
Text {
width: parent.width
text: "Repair " + (root.repairTarget ? String(root.repairTarget.sessionId).substring(0, 8) : "")
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: {
if (!root.repairTarget) return ""
var p = (root.repairTarget.providers || []).join(", ")
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
+ " output while the mode was switched under it, and Anthropic will not "
+ "resume it. Rolling it back to its last good message drops "
+ root.repairTarget.dropLines + " lines."
}
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: "The original is backed up, the dropped turns are saved as Markdown, "
+ "and handed back to the session so it still knows what it did."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Repair it"; primary: true; onTriggered: root.doRepair() }
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
PanelSeparator { width: parent.width; visible: root.modelRows.length > 0 && root.stage === "list" }
Column {