From 4c36f4d00bf1322aee2b84e51228f55192a91d12 Mon Sep 17 00:00:00 2001 From: smoido Date: Mon, 31 Aug 2026 02:28:24 +0300 Subject: [PATCH] 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. --- README.md | 17 +++ linux/claude-mode | 54 ++++---- linux/cm-json.py | 106 +++++++++++++++ omarchy/smoido.claude-mode/BarWidget.qml | 85 +++++++++++- omarchy/smoido.claude-mode/Panel.qml | 163 +++++++++++++++++++++++ 5 files changed, 393 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6bc6357..c635a43 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/linux/claude-mode b/linux/claude-mode index 82fc7ad..daf6450 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -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 --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 --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 diff --git a/linux/cm-json.py b/linux/cm-json.py index fc6acd0..1d17bb0 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -16,6 +16,7 @@ Subcommands: presets "nameproviderdesc" """ +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 - 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, diff --git a/omarchy/smoido.claude-mode/BarWidget.qml b/omarchy/smoido.claude-mode/BarWidget.qml index 6d182d1..ab556e5 100644 --- a/omarchy/smoido.claude-mode/BarWidget.qml +++ b/omarchy/smoido.claude-mode/BarWidget.qml @@ -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,14 +181,34 @@ BarWidget { anchors.centerIn: parent spacing: root.gap - BrandIcon { - id: icon + Item { anchors.verticalCenter: parent.verticalCenter + width: icon.width + height: icon.height visible: root.logo !== "" - pathData: root.logo - opticalScale: Modes.logoScale(root.mode) - color: root.activeColor - iconSize: root.iconPx + + BrandIcon { + id: icon + 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 @@ -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 diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 70c51fa..68200c4 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -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 {