diff --git a/README.md b/README.md index bf83ce7..4b2e414 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -150 of them, under `tests/`. Run the lot, or a subset by pattern: +151 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index a26ff8f..2d5db5e 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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 diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index cd8e052..27c9f59 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -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") diff --git a/config/dot/quickshell/modules/bar/AgentUsageWidget.qml b/config/dot/quickshell/modules/bar/AgentUsageWidget.qml new file mode 100644 index 0000000..6d9ce0a --- /dev/null +++ b/config/dot/quickshell/modules/bar/AgentUsageWidget.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/bar/Bar.qml b/config/dot/quickshell/modules/bar/Bar.qml index 8df1eee..4dc11d5 100644 --- a/config/dot/quickshell/modules/bar/Bar.qml +++ b/config/dot/quickshell/modules/bar/Bar.qml @@ -78,6 +78,8 @@ PanelWindow { screen: root.screen } + AgentUsageWidget {} + VitalsWidget { anchors.verticalCenter: parent.verticalCenter } diff --git a/config/dot/quickshell/scripts/panama-agent-usage b/config/dot/quickshell/scripts/panama-agent-usage new file mode 100755 index 0000000..5536dd4 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-agent-usage @@ -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//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" diff --git a/config/dot/quickshell/services/AgentUsage.qml b/config/dot/quickshell/services/AgentUsage.qml new file mode 100644 index 0000000..6469e3d --- /dev/null +++ b/config/dot/quickshell/services/AgentUsage.qml @@ -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 = ""; + } +} diff --git a/config/local/share/vicinae/scripts/settings-appearance b/config/local/share/vicinae/scripts/settings-appearance index 21c6b52..200a0f8 100755 --- a/config/local/share/vicinae/scripts/settings-appearance +++ b/config/local/share/vicinae/scripts/settings-appearance @@ -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 diff --git a/docs/settings.md b/docs/settings.md index a2dd6a5..0a7c2c2 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -146 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +147 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -317,6 +317,7 @@ Found on **Appearance**. | **Memory**
`showMemory` | true | Show memory usage beside the workspace indicator | | **Graphics**
`showGpu` | true | Show graphics usage beside the workspace indicator | | **Battery**
`showBattery` | true | Show the charge level in the bar, on machines that have a battery | +| **Claude usage**
`showAgentUsage` | false | Show how much of the Claude subscription has been used, beside the other vitals | | **Vitals refresh**
`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. | ## wallpaper diff --git a/tests/quickshell/agent-usage-contract b/tests/quickshell/agent-usage-contract new file mode 100755 index 0000000..e680982 --- /dev/null +++ b/tests/quickshell/agent-usage-contract @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +# How much of the Claude subscription is gone, in the bar. +# +# This is the only thing in Panama that reads an authentication token, so most +# of what is pinned here is about that rather than about the number: +# +# 1. The token never reaches argv. A header passed as an argument is +# world-readable in /proc//cmdline for as long as the request takes, +# which is the same rule the password and MOK paths already follow. +# 2. The token never reaches the output. The widget has no business seeing a +# credential and neither does anyone reading the state file. +# 3. It NEVER refreshes the token and never writes to the credentials file. +# That token expires hourly and Claude Code refreshes it on demand; two +# processes rotating one credential means being silently signed out of +# Claude Code by a status widget, and no bar indicator is worth that. +# 4. An expired token is reported as waiting, not as an error, and no request +# is made with it. +# 5. The widget hides unless it was asked for AND there are real numbers. An +# indicator reading "unknown" is worse than an empty space. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +collector="$repo_dir/config/dot/quickshell/scripts/panama-agent-usage" +service="$repo_dir/config/dot/quickshell/services/AgentUsage.qml" +widget="$repo_dir/config/dot/quickshell/modules/bar/AgentUsageWidget.qml" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +aliases="$repo_dir/config/dot/quickshell/config/Settings.qml" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$collector" ]] || { printf 'agent usage contract: %s is not executable\n' "$collector" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +stub="$work/bin" +mkdir -p "$stub" +export XDG_STATE_HOME="$work/state" +output="$XDG_STATE_HOME/panama/agent-usage.json" + +readonly SECRET='sk-fixture-token-must-never-appear' + +credentials() { + local expires="$1" + cat >"$work/credentials.json" <"$stub/curl" <>"$work/curl-argv" +printf '%s\n' '{"five_hour":{"utilization":0.42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":0.71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}' +STUB +chmod +x "$stub/curl" + +run() { + PATH="$stub:$PATH" PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \ + PANAMA_AGENT_USAGE_ENDPOINT="https://example.invalid/usage" \ + "$collector" >/dev/null 2>&1 +} + +# ── 4. An expired token waits rather than failing ─────────────────────────── + +: >"$work/curl-argv" +credentials 1000 +run +[[ "$(jq -r .status "$output")" == "stale" ]] \ + || note "an expired token did not report as stale (got: $(jq -r .status "$output"))" +[[ -s "$work/curl-argv" ]] \ + && note 'a request was made with an expired token' + +# ── 1 & 2. The token stays out of argv and out of the output ──────────────── + +: >"$work/curl-argv" +credentials "$(( ($(date +%s) + 3600) * 1000 ))" +run + +[[ "$(jq -r .status "$output")" == "ok" ]] \ + || note "a valid token did not produce a reading (got: $(jq -r .detail "$output"))" + +grep -qF "$SECRET" "$work/curl-argv" \ + && note 'the token was passed to curl as an argument, where /proc makes it world-readable' +grep -q -- '--config' "$work/curl-argv" \ + || note 'the token is not passed through a curl config file, so it may reach argv' + +grep -qrF "$SECRET" "$XDG_STATE_HOME" \ + && note 'the token appears in the state file the widget reads' + +# ── 3. Credentials are never written ──────────────────────────────────────── + +before="$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" +run +[[ "$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" == "$before" ]] \ + || note 'the collector modified the credentials file' + +# Comments stripped: the header explains at length that it does not refresh, +# and matching that is matching documentation. +uncommented() { grep -v '^[[:space:]]*#' "$1"; } +uncommented "$collector" | grep -qE 'refreshToken|refresh_token|grant_type' \ + && note 'the collector touches the refresh token, which can sign Claude Code out' +uncommented "$collector" | grep -qE '>[[:space:]]*"?\$?\{?CREDENTIALS' \ + && note 'the collector writes to the credentials file' + +# ── The reading it produced ───────────────────────────────────────────────── + +[[ "$(jq -r '.usage.fiveHour.used' "$output")" == "42" ]] \ + || note 'the five-hour utilisation was not converted to a percentage' +[[ "$(jq -r '.usage.week.used' "$output")" == "71" ]] \ + || note 'the weekly utilisation was not converted to a percentage' + +# An endpoint that answers with something else must degrade, not crash. +cat >"$stub/curl" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' '{"error":{"message":"nope"}}' +STUB +chmod +x "$stub/curl" +run +[[ "$(jq -r .status "$output")" == "unavailable" ]] \ + || note 'an error response was not reported as unavailable' + +# ── 5. The widget hides itself ────────────────────────────────────────────── + +grep -q 'Settings.showAgentUsage && AgentUsage.available' "$widget" \ + || note 'the widget does not gate on both the preference and having real numbers' +grep -q 'key: "showAgentUsage"' "$schema" || note 'there is no showAgentUsage preference' +grep -A2 'key: "showAgentUsage"' "$schema" | grep -q 'def: false' \ + || note 'the usage widget is on by default; it is a coding-tool readout, not general-purpose desktop furniture' +grep -q 'showAgentUsage' "$aliases" \ + || note 'Settings.qml does not alias showAgentUsage, so the binding reads undefined' + +# The collector runs on a timer measured in minutes, never on a repaint. +python3 - "$service" <<'PY' || note 'the collector is not run on a minute-scale timer' +import re, sys +text = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r"interval:\s*(\d+)\s*\*\s*60\s*\*\s*1000", text) +if not match or int(match.group(1)) < 1: + raise SystemExit(1) +PY + +if (( ${#findings[@]} > 0 )); then + printf 'agent usage contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'agent usage contract: PASS\n'