Show how much of the subscription is gone, without risking the session

The last of Section F, and the only thing in Panama that reads an
authentication token, so most of the design is about that rather than
about the number.

It never refreshes the token and never writes to the credentials file.
That token expires roughly hourly and Claude Code refreshes it on
demand; if this refreshed it too, two processes would be rotating one
credential, and a rotation invalidates the other holder's copy. The
failure mode is being silently signed out of Claude Code by a status
widget, which no bar indicator is worth. So it reads the token, uses it
while valid, and reports "waiting for Claude Code to refresh" when not
-- which covers the case that matters, because while you are using
Claude Code the token is fresh, and while you are not there is nothing
to watch.

The token never reaches argv either: curl takes the Authorization
header on stdin through --config, because a header passed as an
argument sits in /proc/<pid>/cmdline for the length of the request.
Same rule the password and MOK paths already follow. And it never
reaches the output: the record carries percentages and timestamps and
nothing else. Both are pinned, and both were checked by sabotaging the
collector to pass -H and watching the contract name it.

Off by default. It is a coding-tool readout, not something a
general-purpose desktop shows without being asked, and it hides unless
the collector has real numbers rather than displaying "unknown".
This commit is contained in:
Gabriel Brown
2026-08-22 08:33:39 -04:00
parent 7a5e990439
commit 8b96d907a1
10 changed files with 419 additions and 3 deletions
@@ -93,6 +93,14 @@ Singleton {
detail: "Show the charge level in the bar, on machines that have a battery"
},
// Off by default: this is a coding-tool readout, not something a
// general-purpose desktop should show without being asked.
{
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
label: "Claude usage",
detail: "Show how much of the Claude subscription has been used, beside the other vitals"
},
// ── Battery ─────────────────────────────────────────────────────────
// The two points at which the desktop starts telling you. Low is a
// quiet mention; critical is the one that interrupts, so it is
@@ -39,6 +39,7 @@ Singleton {
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
readonly property bool showBattery: DesktopPreferences.get("showBattery")
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
// ── Battery ─────────────────────────────────────────────────────────────
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
@@ -0,0 +1,48 @@
// How much of the Claude subscription is gone, beside the other vitals.
//
// One number: whichever window is closer to its limit, because that is the one
// about to interrupt you.
//
// Hidden unless asked for AND the collector has real numbers. A bar indicator
// reading "unknown" is worse than an empty space, and this is off by default:
// it is a coding-tool readout, not something a general-purpose desktop shows
// without being asked.
//
// Children go straight into Pill's own Row -- it adopts them through its
// default alias, so wrapping them in another Row and centring that is both
// redundant and a warning at load.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: Settings.showAgentUsage && AgentUsage.available
interactive: false
onSecondaryActivated: ShellState.openSettings("appearance")
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F1719}" // md-robot-outline
color: {
if (AgentUsage.headline >= 90) return Theme.danger;
if (AgentUsage.headline >= 75) return Theme.warn;
return Theme.fgDim;
}
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: AgentUsage.headline + "%"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
}
}
@@ -78,6 +78,8 @@ PanelWindow {
screen: root.screen
}
AgentUsageWidget {}
VitalsWidget {
anchors.verticalCenter: parent.verticalCenter
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# How much of the Claude subscription this account has used.
#
# Writes one display-ready record to $XDG_STATE_HOME/panama/agent-usage.json.
# The bar widget only ever reads that file, so adding a second agent later is a
# collector rather than a change to any QML.
#
# ── What this deliberately does NOT do ───────────────────────────────────────
#
# It never refreshes the OAuth token, and it never writes to
# ~/.claude/.credentials.json.
#
# That token expires about hourly and Claude Code refreshes it on demand. If
# this refreshed it too, two processes would be rotating one credential: a
# refresh that rotates the refresh token invalidates the other holder's copy,
# and the failure mode is being silently logged out of Claude Code by a status
# widget. No bar indicator is worth that.
#
# So this reads the token, uses it if it is still valid, and reports
# "unavailable" if it is not. In practice that covers the case that matters --
# while you are actually using Claude Code the token is fresh, and while you
# are not, there is nothing to watch.
#
# ── The token ────────────────────────────────────────────────────────────────
#
# Never reaches argv. `curl --config -` takes the Authorization header on
# stdin, because a header passed as an argument is world-readable in
# /proc/<pid>/cmdline for as long as the request takes -- the same rule
# panama-pick follows for passwords and panama-sudo for the MOK hash.
#
# Never reaches the output either. The record below carries percentages and
# timestamps and nothing else; the widget has no business seeing a credential
# and neither does anyone reading the state file.
set -uo pipefail
CREDENTIALS="${PANAMA_AGENT_CREDENTIALS:-$HOME/.claude/.credentials.json}"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
OUTPUT="$STATE_DIR/agent-usage.json"
ENDPOINT="${PANAMA_AGENT_USAGE_ENDPOINT:-https://api.anthropic.com/api/oauth/usage}"
mkdir -p "$STATE_DIR"
# Written whatever happens, so the widget can distinguish "no data yet" from
# "collector never ran" and hide itself for the right reason.
emit() {
local status="$1" detail="${2:-}" body="${3:-null}"
local tmp
tmp="$(mktemp "$OUTPUT.XXXXXX")"
jq -n --arg status "$status" --arg detail "$detail" \
--argjson usage "$body" --arg at "$(date -Is)" \
'{status: $status, detail: $detail, collectedAt: $at, usage: $usage}' \
>"$tmp" 2>/dev/null || printf '{"status":"error","detail":"could not write","usage":null}' >"$tmp"
mv "$tmp" "$OUTPUT"
}
command -v jq >/dev/null 2>&1 || exit 0
[[ -r "$CREDENTIALS" ]] || { emit unavailable "Claude Code is not signed in on this machine."; exit 0; }
expires="$(jq -r '.claudeAiOauth.expiresAt // 0' "$CREDENTIALS" 2>/dev/null)"
[[ "$expires" =~ ^[0-9]+$ ]] || expires=0
now="$(( $(date +%s) * 1000 ))"
# Thirty seconds of headroom: a token about to expire will have expired by the
# time the request lands, and a 401 is a worse answer than an honest wait.
if (( expires <= now + 30000 )); then
emit stale "Waiting for Claude Code to refresh its session."
exit 0
fi
config="$(mktemp)"
cleanup() { rm -f "$config"; }
trap cleanup EXIT
chmod 600 "$config"
jq -r '"header = \"Authorization: Bearer \(.claudeAiOauth.accessToken)\"\nheader = \"anthropic-beta: oauth-2025-04-20\"\nsilent\nshow-error"' \
"$CREDENTIALS" >"$config" 2>/dev/null \
|| { emit unavailable "Could not read the Claude Code session."; exit 0; }
response="$(curl --max-time 10 --config "$config" "$ENDPOINT" 2>/dev/null)" || {
emit unavailable "Could not reach the usage service."
exit 0
}
rm -f "$config"
jq -e . >/dev/null 2>&1 <<<"$response" || { emit unavailable "The usage service returned something unreadable."; exit 0; }
if jq -e '.error' >/dev/null 2>&1 <<<"$response"; then
emit unavailable "$(jq -r '.error.message // "The usage service refused the request."' <<<"$response")"
exit 0
fi
# Reshaped into a small, stable record rather than passed through, so the
# widget does not depend on the shape of an endpoint nobody documents. Every
# field is optional: an endpoint that stops reporting one should cost that
# number, not the whole indicator.
usage="$(jq -c '
def pct: if type == "number" then (. * 100 | round) else null end;
{
tier: (.rate_limit_tier // .rateLimitTier // null),
subscription: (.subscription_type // .subscriptionType // null),
fiveHour: {
used: ((.five_hour.utilization // .fiveHour.utilization // null) | pct),
resetsAt: (.five_hour.resets_at // .fiveHour.resetsAt // null)
},
week: {
used: ((.seven_day.utilization // .week.utilization // null) | pct),
resetsAt: (.seven_day.resets_at // .week.resetsAt // null)
}
}' <<<"$response" 2>/dev/null)"
[[ -n "$usage" ]] || { emit unavailable "The usage service returned an unfamiliar shape."; exit 0; }
emit ok "" "$usage"
@@ -0,0 +1,92 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// How much of the Claude 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.
//
// 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
// indicator that says "unknown" is worse than an empty space.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// "ok" | "stale" | "unavailable" | "" (never collected)
property string status: ""
property string detail: ""
// 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 bool available: root.status === "ok"
&& (root.fiveHourUsed >= 0 || root.weekUsed >= 0)
// 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)
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"
// 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
running: Settings.showAgentUsage
repeat: true
triggeredOnStart: true
onTriggered: collect.running = true
}
Process {
id: collect
command: [root.helperPath]
onExited: record.reload()
}
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;
}
} catch (error) {
root.status = "";
}
}
onLoadFailed: root.status = "";
}
}
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Appearance in Settings.
# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "inner gaps", "outer gaps", "border width", "corner radius", "unfocused window opacity"]
# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "claude usage", "inner gaps", "outer gaps", "border width", "corner radius"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page appearance