No error is a dead end: crash, click, and your agent is already looking

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 12:50:09 -04:00
parent ada0faf1d1
commit cc7d91d09c
43 changed files with 4648 additions and 327 deletions
+144 -55
View File
@@ -1,15 +1,23 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// How much of the Claude subscription this account has used.
// How much of each agent subscription this account has used.
//
// The collector writes one display-ready record and this only ever reads it.
// That split is the point: adding a second agent later is a collector, not a
// change here or in the widget, and nothing in QML ever sees a credential.
// A collector per agent writes one display-ready record into
// $XDG_STATE_HOME/panama/agents/usage/<agent>.json, and this only ever reads
// that directory. The split is the point: adding a third agent is a collector,
// not a change here, in the widget, or in the panel -- and nothing in QML ever
// sees a credential.
//
// The record carries its own status, so this can tell the three cases apart:
// the collector has never run, it ran and the session was stale, or it has
// real numbers. The widget hides for the first two, which is right -- a bar
// Discovery is by listing the directory rather than by naming the agents, so a
// record that appears is an agent that appears. Each record gets its own
// FileView with watchChanges, so a collector finishing mid-session updates the
// panel without waiting for the next tick.
//
// Every record carries its own honesty: `ready` says whether it has anything
// worth showing, `usageStatusText` says why not when it does not, and
// `retryAdvised` says a transport failure is worth retrying sooner than the
// interval. The widget hides when nothing is ready, which is right -- a bar
// indicator that says "unknown" is worse than an empty space.
// ─────────────────────────────────────────────────────────────────────────────
@@ -21,72 +29,153 @@ import qs.config
Singleton {
id: root
// "ok" | "stale" | "unavailable" | "" (never collected)
property string status: ""
property string detail: ""
readonly property string usageDir:
(Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
+ "/panama/agents/usage"
// Percentages, 0-100, or -1 when the endpoint did not report one.
property int fiveHourUsed: -1
property int weekUsed: -1
property string weekResetsAt: ""
property string tier: ""
readonly property string updaterPath: Quickshell.shellDir + "/scripts/panama-agent-usage-update"
readonly property bool available: root.status === "ok"
&& (root.fiveHourUsed >= 0 || root.weekUsed >= 0)
// Agent ids with a record file on disk, in the order the directory listed
// them. Reassigned rather than mutated: QML does not notify on in-place
// changes to a var property's contents.
property var agentIds: []
// id -> parsed record, for the ids above. Same reassignment rule.
property var records: ({})
// The records worth drawing. A collector that ran but has nothing to say
// (never signed in, never used) reports ready:false rather than zeros.
readonly property var readyRecords: root.agentIds
.map(id => root.records[id])
.filter(record => record && record.ready === true)
// The number worth showing when there is only room for one: whichever
// window is closer to its limit is the one about to interrupt you.
readonly property int headline: Math.max(root.fiveHourUsed, root.weekUsed)
// window across every agent is closest to its limit is the one about to
// interrupt you. -1 when nothing has a limit to report.
readonly property int headline: {
let fullest = -1;
for (const record of root.readyRecords) {
const limits = Array.isArray(record.limits) ? record.limits : [];
for (const limit of limits) {
const percent = Number(limit?.percent);
if (Number.isFinite(percent))
fullest = Math.max(fullest, percent);
}
}
return fullest < 0 ? -1 : Math.round(Math.min(1, Math.max(0, fullest)) * 100);
}
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-agent-usage"
readonly property string statePath:
(Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
+ "/panama/agent-usage.json"
readonly property bool available: root.headline >= 0
// Any agent asking to be retried sooner than the interval. A first probe
// after login often fires before DHCP has handed out a route, and waiting a
// quarter of an hour to find out is not an answer.
readonly property bool retryAdvised: root.agentIds
.some(id => root.records[id]?.retryAdvised === true)
// Minutes, never a repaint. Usage moves slowly and the collectors make a
// network call; anything faster would spend someone's battery watching a
// number that changes a few times an hour. Clamped so a hand-edited
// settings.json cannot turn the bar into a request loop.
readonly property int refreshMinutes: Math.max(5, Math.min(60, Settings.agentUsageRefreshMinutes))
function refresh(force: bool): void {
if (collect.running)
return;
collect.command = force ? [root.updaterPath, "--force"] : [root.updaterPath];
collect.running = true;
}
// Called by the panel when it opens: the local scans may be reused, but the
// limits are what the panel is being opened to read.
function refreshLimits(): void {
if (collect.running)
return;
collect.command = [root.updaterPath, "--limits-only"];
collect.running = true;
}
function absorb(id: string, text: string): void {
const next = Object.assign({}, root.records);
try {
const parsed = JSON.parse(text);
next[id] = (parsed && typeof parsed === "object") ? parsed : null;
} catch (error) {
next[id] = null;
}
root.records = next;
}
function forget(id: string): void {
const next = Object.assign({}, root.records);
delete next[id];
root.records = next;
}
// Minutes, never a repaint. Usage moves slowly and the collector makes a
// network call; anything faster would be spending someone's battery to
// watch a number that changes a few times an hour.
Timer {
interval: 5 * 60 * 1000
id: tick
interval: root.refreshMinutes * 60 * 1000
running: Settings.showAgentUsage
repeat: true
triggeredOnStart: true
onTriggered: collect.running = true
onTriggered: root.refresh(false)
}
// The one short timer in here, and it only ever runs while a collector has
// said its failure was a transport failure rather than an answer.
Timer {
interval: 30 * 1000
running: Settings.showAgentUsage && root.retryAdvised
repeat: true
onTriggered: root.refresh(false)
}
Process {
id: collect
command: [root.helperPath]
onExited: record.reload()
command: [root.updaterPath]
onExited: scan.running = true
}
FileView {
id: record
path: root.statePath
printErrors: false
watchChanges: true
onFileChanged: this.reload()
onLoaded: {
try {
const parsed = JSON.parse(this.text());
root.status = String(parsed.status ?? "");
root.detail = String(parsed.detail ?? "");
const usage = parsed.usage;
if (usage) {
root.fiveHourUsed = Number.isFinite(usage.fiveHour?.used)
? usage.fiveHour.used : -1;
root.weekUsed = Number.isFinite(usage.week?.used)
? usage.week.used : -1;
root.weekResetsAt = String(usage.week?.resetsAt ?? "");
root.tier = String(usage.tier ?? "");
} else {
root.fiveHourUsed = -1;
root.weekUsed = -1;
// Which records exist. `sh -c` with the directory passed as an argument
// rather than interpolated: a path is data, and a state directory is not
// somewhere to build a command string from.
Process {
id: scan
command: ["sh", "-c", 'ls -1 "$1" 2>/dev/null', "sh", root.usageDir]
stdout: StdioCollector {
onStreamFinished: {
const found = [];
for (const line of String(this.text ?? "").split("\n")) {
const name = line.trim();
if (name.endsWith(".json") && name.length > 5)
found.push(name.slice(0, -5));
}
} catch (error) {
root.status = "";
for (const id of root.agentIds)
if (found.indexOf(id) < 0)
root.forget(id);
root.agentIds = found;
}
}
onLoadFailed: root.status = "";
}
Instantiator {
model: root.agentIds
delegate: FileView {
required property var modelData
path: root.usageDir + "/" + modelData + ".json"
printErrors: false
watchChanges: true
onFileChanged: this.reload()
onLoaded: root.absorb(modelData, this.text())
onLoadFailed: root.forget(modelData)
}
}
// Whatever the collectors last left behind is worth showing before the
// first tick lands, so the panel is not empty for the length of a probe.
// The directory may not exist yet on a machine where no collector has ever
// run; the listing fails quietly and the next fan-out creates it.
Component.onCompleted: scan.running = true
}