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:
@@ -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
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user