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
@@ -168,8 +168,60 @@ Singleton {
// 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"
label: "Agent usage",
detail: "Show how much of the busiest agent subscription has been used, beside the other vitals"
},
// ── Agents ──────────────────────────────────────────────────────────
// The escalation ladder and the usage collectors. `preferredAgent` is
// deliberately "none" out of the box: until an agent is chosen, crash
// notifications carry no action -- the desktop stays quiet rather than
// volunteering a tool the user never asked for.
{
key: "preferredAgent", type: "enum", def: "none", group: "agents",
label: "Preferred agent",
detail: "Who answers when the desktop offers to investigate something",
options: [
{ value: "none", label: "None" },
{ value: "claude", label: "Claude Code" },
{ value: "codex", label: "Codex" }
]
},
{
key: "crashDiagnoseOffer", type: "bool", def: true, group: "agents",
label: "Offer to diagnose crashes",
detail: "When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand"
},
{
key: "reloadFailureOffer", type: "bool", def: true, group: "agents",
label: "Offer help when the shell fails to reload",
detail: "A broken change to the shell's own configuration offers the failing log to the agent"
},
{
key: "healthAgentHandoff", type: "bool", def: true, group: "agents",
label: "System Health hands off unrepairable checks",
detail: "A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot"
},
{
key: "agentAutoApprove", type: "bool", def: true, group: "agents",
label: "Launched agents approve their own tools",
detail: "Investigations run without permission prompts. The diagnose skill still holds agents to reading rather than fixing, and root still goes through panama-sudo, reason and all"
},
{
key: "agentUsageClaude", type: "bool", def: true, group: "agents",
label: "Collect Claude Code usage",
detail: "Limits from Anthropic's usage endpoint, tokens from the local transcripts"
},
{
key: "agentUsageCodex", type: "bool", def: true, group: "agents",
label: "Collect Codex usage",
detail: "Limits over the Codex app-server, sessions from its local files"
},
{
key: "agentUsageRefreshMinutes", type: "int", def: 15, min: 5, max: 60, step: 5,
unit: " min", group: "agents",
label: "Refresh interval",
detail: "How often the usage collectors ask for fresh numbers, in minutes"
},
// ── Battery ─────────────────────────────────────────────────────────
+13
View File
@@ -62,6 +62,19 @@ Singleton {
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
// ── Agents ──────────────────────────────────────────────────────────────
// Who the desktop hands a failure to, what it is allowed to hand over, and
// which usage collectors run. `showAgentUsage` stays with the vitals above:
// it is the bar's switch, and the Agents page mirrors it.
readonly property string preferredAgent: DesktopPreferences.get("preferredAgent")
readonly property bool crashDiagnoseOffer: DesktopPreferences.get("crashDiagnoseOffer")
readonly property bool reloadFailureOffer: DesktopPreferences.get("reloadFailureOffer")
readonly property bool healthAgentHandoff: DesktopPreferences.get("healthAgentHandoff")
readonly property bool agentAutoApprove: DesktopPreferences.get("agentAutoApprove")
readonly property bool agentUsageClaude: DesktopPreferences.get("agentUsageClaude")
readonly property bool agentUsageCodex: DesktopPreferences.get("agentUsageCodex")
readonly property int agentUsageRefreshMinutes: DesktopPreferences.get("agentUsageRefreshMinutes")
// ── Battery ─────────────────────────────────────────────────────────────
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
@@ -0,0 +1,592 @@
// The whole story behind the bar's one number.
//
// The bar shows the fullest window across every agent, because that is the one
// about to interrupt you. This is what that number is made of: each agent's
// limits with their reset times, what today cost, and where the tokens went.
//
// A Popover anchored under the widget, the way TrayMenu hangs off a tray icon —
// the house pattern for anything that belongs to a bar item rather than to the
// shell. Clicking outside closes it; so does Escape, which Popover's focus grab
// handles for every popover in the shell.
//
// Nothing in here animates on a timer. One 30-second tick advances the clock
// that "updated 4 minutes ago" and the reset countdowns read, and it only runs
// while the panel is open.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
implicitWidth: Theme.popoverWidth
implicitHeight: body.implicitHeight + contentPadding * 2
// Which agent's tab is showing. Empty means "whichever is first", so the
// panel is never blank because a collector was switched off between opens.
property string selectedId: ""
readonly property var agents: AgentUsage.readyRecords
readonly property var record: {
const list = root.agents;
if (list.length === 0)
return null;
for (const candidate of list)
if (candidate.id === root.selectedId)
return candidate;
return list[0];
}
// One clock for the whole panel, ticking only while it is open. Every
// elapsed-time and countdown string in here reads this instead of calling
// Date.now() in a binding, which would never invalidate.
property double nowMs: Date.now()
onVisibleChanged: {
if (root.visible) {
root.nowMs = Date.now();
// The local scans can be reused; the limits are what the panel is
// being opened to read.
AgentUsage.refreshLimits();
}
}
Timer {
interval: 30 * 1000
running: root.visible
repeat: true
onTriggered: root.nowMs = Date.now()
}
// ── Formatting ──────────────────────────────────────────────────────────
function tokenText(value: double): string {
const n = Number(value) || 0;
if (n >= 1e9)
return (n / 1e9).toFixed(1) + "B";
if (n >= 1e6)
return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3)
return Math.round(n / 1e3) + "k";
return String(Math.round(n));
}
function percentText(fraction: real): string {
return Math.round(Math.min(1, Math.max(0, Number(fraction) || 0)) * 100) + "%";
}
function meterColor(fraction: real): color {
const percent = (Number(fraction) || 0) * 100;
if (percent >= 90)
return Theme.danger;
if (percent >= 75)
return Theme.warn;
return Theme.accent;
}
function parseTime(iso: string): double {
const parsed = Date.parse(String(iso ?? ""));
return isNaN(parsed) ? 0 : parsed;
}
// "Updated 4 minutes ago". A record with no timestamp says so rather than
// implying it is current.
function agoText(iso: string): string {
const at = root.parseTime(iso);
if (at <= 0)
return "Never collected";
const minutes = Math.floor(Math.max(0, root.nowMs - at) / 60000);
if (minutes < 1)
return "Updated just now";
if (minutes === 1)
return "Updated 1 minute ago";
if (minutes < 60)
return `Updated ${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
return hours === 1 ? "Updated 1 hour ago" : `Updated ${hours} hours ago`;
}
// "resets 2:40 pm" for something today, "resets Thu" for something further
// out. A window whose reset has passed says so rather than counting into
// the negative — the collector keeps a cached limit only until its window
// rolls over, so this is a record caught mid-rollover.
function resetText(iso: string): string {
const at = root.parseTime(iso);
if (at <= 0)
return "";
if (at <= root.nowMs)
return "resetting";
const when = new Date(at);
const sameDay = new Date(root.nowMs).toDateString() === when.toDateString();
if (sameDay)
return "resets " + when.toLocaleTimeString(Qt.locale(), "h:mm ap");
return "resets " + when.toLocaleDateString(Qt.locale(), "ddd");
}
function weekdayText(date: string): string {
const parts = String(date ?? "").split("-");
if (parts.length !== 3)
return "";
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
return when.toLocaleDateString(Qt.locale(), "ddd").slice(0, 2);
}
function isToday(date: string): bool {
const parts = String(date ?? "").split("-");
if (parts.length !== 3)
return false;
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
return when.toDateString() === new Date(root.nowMs).toDateString();
}
// ── Derived views of the selected record ────────────────────────────────
readonly property var limits: {
const entries = root.record && Array.isArray(root.record.limits) ? root.record.limits : [];
return entries.filter(entry => entry && Number.isFinite(Number(entry.percent)));
}
readonly property var days: {
const entries = root.record && Array.isArray(root.record.recentDays) ? root.record.recentDays : [];
// recentDays.messageCount is a token total, despite the legacy name the
// collectors inherited.
return entries.map(day => ({
date: String(day?.date ?? ""),
tokens: Number(day?.messageCount) || 0
}));
}
readonly property double dayPeak: {
let peak = 0;
for (const day of root.days)
peak = Math.max(peak, day.tokens);
return peak;
}
// Top models by total tokens. Five rows is the whole point of the section:
// more than that and it stops being a glance.
readonly property var models: {
const usage = root.record?.modelUsage;
if (!usage || typeof usage !== "object")
return [];
const rows = [];
for (const name of Object.keys(usage)) {
const bucket = usage[name] || {};
const total = (Number(bucket.inputTokens) || 0)
+ (Number(bucket.outputTokens) || 0)
+ (Number(bucket.cacheReadInputTokens) || 0)
+ (Number(bucket.cacheCreationInputTokens) || 0);
if (total > 0)
rows.push({ name: name, tokens: total });
}
rows.sort((a, b) => b.tokens - a.tokens);
return rows.slice(0, 5);
}
readonly property double modelPeak: root.models.length > 0 ? root.models[0].tokens : 0
Column {
id: body
width: parent.width
spacing: 8
// ── Agent tabs ──────────────────────────────────────────────────────
//
// Only worth drawing when there is a choice to make.
Row {
width: parent.width
spacing: 6
visible: root.agents.length > 1
Repeater {
model: root.agents
delegate: Rectangle {
id: tab
required property var modelData
readonly property bool current: root.record && root.record.id === tab.modelData.id
width: (body.width - 6 * (root.agents.length - 1)) / root.agents.length
height: 28
radius: 9
border.width: tab.current ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.3)
color: tab.current
? Theme.alpha(Theme.accent, 0.14)
: (tabMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.05))
Text {
anchors.centerIn: parent
text: tab.modelData.name || tab.modelData.id
color: tab.current ? Theme.fg : Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: tabMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selectedId = String(tab.modelData.id ?? "")
}
}
}
}
// ── Who, and how current ────────────────────────────────────────────
Item {
width: parent.width
implicitHeight: Math.max(heroGlyph.implicitHeight, heroName.implicitHeight, tierChip.implicitHeight)
Text {
id: heroGlyph
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "\u{F1719}" // md-robot-outline
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
}
Text {
id: heroName
anchors.left: heroGlyph.right
anchors.leftMargin: 9
anchors.right: tierChip.visible ? tierChip.left : parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.record?.name ?? "Agent usage"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
// The plan, when the collector could name one. It is the only thing
// from the credential store allowed into a record.
Rectangle {
id: tierChip
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: String(root.record?.tierLabel ?? "") !== ""
implicitWidth: tierText.implicitWidth + 18
implicitHeight: tierText.implicitHeight + 6
radius: Theme.pillRadius
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.25)
color: Theme.alpha(Theme.accent, 0.1)
Text {
id: tierText
anchors.centerIn: parent
text: String(root.record?.tierLabel ?? "").toUpperCase()
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: root.agoText(root.record?.updatedAt)
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
// ── An honest word when the numbers are not authoritative ───────────
Rectangle {
width: parent.width
visible: String(root.record?.usageStatusText ?? "") !== ""
implicitHeight: statusColumn.implicitHeight + 18
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.warn, 0.12)
Column {
id: statusColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 3
Text {
width: parent.width
text: root.record?.usageStatusText ?? ""
color: Theme.warn
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: String(root.record?.authHelpText ?? "") !== ""
text: root.record?.authHelpText ?? ""
color: Theme.fgDim
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// ── Limits ──────────────────────────────────────────────────────────
//
// Model-scoped windows sit in the same list as the flat ones: the
// collector settles which window an entry belongs to and titles it, so
// "Fable Weekly" reads beside "Weekly (7-day)" rather than under it.
Repeater {
model: root.limits
delegate: Column {
id: limitRow
required property var modelData
width: body.width
topPadding: 5
spacing: 6
Item {
width: parent.width
implicitHeight: limitLabel.implicitHeight
Text {
id: limitLabel
anchors.left: parent.left
anchors.right: limitValue.left
anchors.rightMargin: 8
text: limitRow.modelData.label ?? "Limit"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
id: limitValue
anchors.right: parent.right
anchors.baseline: limitLabel.baseline
text: {
const reset = root.resetText(limitRow.modelData.resetsAt);
const percent = root.percentText(limitRow.modelData.percent);
return reset ? reset + " · " + percent : percent;
}
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
}
}
Rectangle {
width: parent.width
height: 8
radius: 4
border.width: 0
color: Theme.alpha(Theme.fg, 0.08)
Rectangle {
width: Math.max(0, Math.min(1, Number(limitRow.modelData.percent) || 0)) * parent.width
height: parent.height
radius: parent.radius
border.width: 0
color: root.meterColor(limitRow.modelData.percent)
}
}
}
}
Rectangle {
width: parent.width
visible: root.days.length > 0 || root.models.length > 0
height: 1
color: Theme.alpha(Theme.fg, 0.08)
}
// ── Tokens, last seven days ─────────────────────────────────────────
Item {
width: parent.width
visible: root.dayPeak > 0
implicitHeight: dayHeader.implicitHeight + 8 + 56
Text {
id: dayHeader
anchors.left: parent.left
text: "Tokens, last 7 days"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.baseline: dayHeader.baseline
text: "today " + root.tokenText(root.record?.todayTotalTokens ?? 0)
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 56
spacing: 6
Repeater {
model: root.days
delegate: Column {
id: dayColumn
required property var modelData
readonly property bool today: root.isToday(dayColumn.modelData.date)
width: (body.width - 6 * 6) / 7
spacing: 4
Item {
width: parent.width
height: 56 - 4 - dayLabel.implicitHeight
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(
dayColumn.modelData.tokens > 0 ? 2 : 0,
root.dayPeak > 0 ? (dayColumn.modelData.tokens / root.dayPeak) * parent.height : 0)
radius: 4
border.width: 0
color: dayColumn.today ? Theme.accent : Theme.alpha(Theme.accent, 0.35)
}
}
Text {
id: dayLabel
width: parent.width
text: root.weekdayText(dayColumn.modelData.date)
color: dayColumn.today ? Theme.fgDim : Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: dayColumn.today ? Font.DemiBold : Font.Normal
}
}
}
}
}
// ── Where the tokens went ───────────────────────────────────────────
Text {
width: parent.width
visible: root.models.length > 0
topPadding: 4
text: "By model"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Repeater {
model: root.models
delegate: Item {
id: modelRow
required property var modelData
width: body.width
implicitHeight: 18
Text {
id: modelName
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 108
text: modelRow.modelData.name
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
}
Item {
anchors.left: modelName.right
anchors.leftMargin: 10
anchors.right: modelValue.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
width: root.modelPeak > 0
? Math.max(2, (modelRow.modelData.tokens / root.modelPeak) * parent.width)
: 0
height: parent.height
radius: 3
border.width: 0
color: Theme.alpha(Theme.accentAlt, 0.55)
}
}
Text {
id: modelValue
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 52
text: root.tokenText(modelRow.modelData.tokens)
color: Theme.fgDim
horizontalAlignment: Text.AlignRight
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.features: Theme.tabularFigures
}
}
}
// ── Sessions, when that is all there is ─────────────────────────────
Text {
width: parent.width
visible: root.dayPeak <= 0 && root.models.length === 0 && root.record !== null
text: `Sessions today: ${root.record?.todaySessions ?? 0}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: root.record === null
text: "No collector has anything to report yet."
color: Theme.fgDim
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
@@ -1,9 +1,9 @@
// How much of the Claude subscription is gone, beside the other vitals.
// How much of the busiest agent 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.
// One number: whichever window across every collected agent is closest 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
// Hidden unless asked for AND a 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.
@@ -14,8 +14,8 @@
// came out sitting off-centre against the rest of the bar.
//
// Clickable, because a readout you cannot ask anything of is furniture. Left
// click opens the settings that govern it; hovering says which window the
// number belongs to and when it resets.
// click opens the panel behind the number; right click opens the settings that
// govern it.
import QtQuick
import qs.config
@@ -27,7 +27,7 @@ Pill {
visible: Settings.showAgentUsage && AgentUsage.available
onActivated: ShellState.openSettings("bar")
onActivated: panel.visible = !panel.visible
onSecondaryActivated: ShellState.openSettings("bar")
Row {
@@ -58,4 +58,11 @@ Pill {
width: 30
}
}
// Hangs off this pill the way TrayMenu hangs off a tray icon. A PopupWindow
// is not an Item, so it takes no space in Pill's layout Row.
AgentUsagePanel {
id: panel
anchorItem: root
}
}
@@ -90,17 +90,43 @@ Rectangle {
return false;
}
// Everything except the buttons: clicking the body runs the notification's
// default action, which is what GNOME does.
// A command the notification carried as data, in the `panama-exec` hint.
// Panama's escalation ladder rides this: a crash watcher that has already
// exited, or an install that failed in a terminal, still gets a clickable
// "diagnose this with your agent" -- the command IS the notification, so
// nothing has to stay alive to service an action and the click survives a
// shell restart. Read once at delivery; see services/Notifs.qml for why
// that is safe and what it deliberately does not promise.
readonly property string execCommand: Notifs.execCommand(root.notification)
readonly property bool bodyActivates: root.defaultAction !== null || root.execCommand !== ""
// Clicking the body runs the notification's default action, which is what
// GNOME does. The sender's own action wins when a notification carries
// both: an application that registered one is asking for ITS handler, and
// the hint exists for senders that cannot stay alive to serve one.
//
// The command runs through `sh -c` because it arrives as a single string
// rather than an argv -- that is the shape the hint can carry. It runs
// detached, so a notification click never blocks or outlives the shell.
function activateBody(): void {
if (root.defaultAction) {
root.defaultAction.invoke();
return;
}
if (root.execCommand === "")
return;
Quickshell.execDetached(["sh", "-c", root.execCommand]);
root.dismissed();
}
// Everything except the buttons.
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.defaultAction)
root.defaultAction.invoke();
}
cursorShape: root.bodyActivates ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.activateBody()
}
IconImage {
@@ -0,0 +1,223 @@
// Agents.
//
// Which AI tool answers when the desktop offers to investigate something, what
// the desktop is allowed to hand it, and how much of your subscription is left.
//
// The preferred agent ships as "none" and that is not a placeholder: until one
// is chosen, every rung of the escalation ladder stays silent -- a crash
// notification carries no action, System Health grows no button, a failed
// reload says only that it failed. A desktop that volunteered a tool nobody
// installed would be worse than one that says nothing.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.config
import qs.services
import qs.widgets
SettingsPage {
id: root
objectName: "agents-page"
title: "Agents"
lede: "Your AI tools, and what the desktop is allowed to hand them."
// The options come from the schema rather than from a list here, so this
// page cannot offer an agent the preference would refuse.
readonly property var agentOptions: PreferenceSchema.spec("preferredAgent")?.options ?? []
readonly property string preferred: String(DesktopPreferences.get("preferredAgent") ?? "none")
// ── Install state, or no claim at all ───────────────────────────────────
//
// A tile says "Not installed" only once something has actually looked. Any
// other order gets it wrong in the direction that matters: a page telling
// somebody their agent is missing, when it is sitting right there, teaches
// them not to believe the page.
//
// Probed through a LOGIN shell rather than this one. Quickshell is started
// by systemd, whose PATH does not include ~/.local/bin -- where both of
// these usually land -- and a login shell is the environment the launcher
// hands the agent when it spawns a terminal. Asking with the shell's own
// PATH would report "not installed" for an agent that starts perfectly.
property var installed: ({})
property bool probed: false
function absorbProbe(text: string): void {
const found = {};
for (const line of String(text).split("\n")) {
const name = line.trim();
if (name !== "")
found[name] = true;
}
root.installed = found;
root.probed = true;
}
Process {
id: agentProbe
running: true
command: ["bash", "-lc",
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
stdout: StdioCollector {
onStreamFinished: root.absorbProbe(this.text)
}
}
function iconFor(value: string): string {
switch (value) {
case "claude": return "starred-symbolic";
case "codex": return "utilities-terminal-symbolic";
default: return "notifications-disabled-symbolic";
}
}
function tileDetail(value: string): string {
if (value === "none")
return "Stay quiet";
if (!root.probed)
return "";
return root.installed[value] === true ? "Installed" : "Not installed";
}
SettingsCard {
title: "Preferred agent"
subtitle: "Who answers when the desktop offers to investigate something. Until one is chosen, crash notifications carry no action — the desktop stays quiet rather than volunteering a tool you do not use."
// The same tile shape the power profiles use: three rows with the word
// "Active" in one of them is a list you have to read to find out what
// is set; three tiles with one lit answers that without reading.
Flow {
id: tiles
width: parent.width
spacing: 10
bottomPadding: 12
readonly property int columns: tiles.width >= 460 ? 3 : 1
readonly property real tileWidth:
(tiles.width - tiles.spacing * (tiles.columns - 1)) / tiles.columns
Repeater {
model: root.agentOptions
Rectangle {
id: tile
required property var modelData
readonly property string value: String(tile.modelData.value)
readonly property bool selected: tile.value === root.preferred
readonly property string detail: root.tileDetail(tile.value)
objectName: `agent-tile:${tile.value}`
width: tiles.tileWidth
implicitHeight: tileBody.implicitHeight + 24
radius: Theme.cardRadius
color: tile.selected
? Theme.alpha(Theme.accent, 0.09)
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
border.width: tile.selected || tile.activeFocus ? 2 : 1
border.color: tile.activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
activeFocusOnTab: true
Accessible.role: Accessible.RadioButton
Accessible.name: String(tile.modelData.label)
Accessible.description: tile.detail
Accessible.checked: tile.selected
// An agent this machine cannot start is still selectable:
// the probe answers for THIS session's login shell, and
// being wrong about that must not lock somebody out of a
// choice they are entitled to make. The tile says what it
// found; the person decides.
function choose(): void {
if (!tile.selected)
SystemSettings.commitPreference("preferredAgent", tile.value);
}
Keys.onReturnPressed: tile.choose()
Keys.onSpacePressed: tile.choose()
Column {
id: tileBody
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 6
ThemedIcon {
icon: root.iconFor(tile.value)
iconFallback: "system-run-symbolic"
size: 20
tint: tile.selected ? Theme.accent : Theme.fgDim
}
Text {
width: parent.width
text: String(tile.modelData.label)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
visible: tile.detail !== ""
text: tile.detail
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
wrapMode: Text.WordWrap
}
}
HoverHandler {
id: tileHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
tile.choose();
tile.forceActiveFocus();
}
}
}
}
}
}
SettingsCard {
title: "When something breaks"
subtitle: "Each of these is a failure that used to be a dead end. Every one still needs an agent chosen above before it offers anything."
ToggleRow { setting: "crashDiagnoseOffer" }
ToggleRow { setting: "reloadFailureOffer" }
ToggleRow { setting: "healthAgentHandoff" }
ToggleRow { setting: "agentAutoApprove"; divider: false }
}
SettingsCard {
title: "Usage in the bar"
subtitle: "The bar shows the fullest limit — the one that stops your next prompt. Clicking it opens the whole picture."
// The same switch the Bar page carries, deliberately: this is the page
// somebody is on when they wonder where the number went, and the Bar
// page is the page they are on when they are choosing what the bar
// contains. Declared as an intentional mirror in
// tests/quickshell/settings-ownership-contract.
ToggleRow { setting: "showAgentUsage" }
ToggleRow { setting: "agentUsageClaude" }
ToggleRow { setting: "agentUsageCodex" }
SliderRow { setting: "agentUsageRefreshMinutes"; divider: false }
}
}
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import qs.config
import qs.services
@@ -66,6 +67,84 @@ SettingsPage {
return detail + " · Repair runs: " + command;
}
// ── The Health rung of the escalation ladder ────────────────────────────
//
// A red check with nothing to press is where this page used to end. It can
// tell you the portals are down; it cannot tell you why, and the honest
// next step -- read the journal, correlate against recent updates -- is
// precisely the work an agent is good at. So the row grows one more button
// carrying what the check knows.
//
// Offered only where it is the LAST resort. A check that offers a repair
// has a better answer than a conversation, right up until that repair has
// actually been run and failed.
readonly property bool agentHandoffAvailable: {
if (DesktopPreferences.get("healthAgentHandoff") !== true)
return false;
// No agent chosen is the shipped default, and it means what it says:
// no button, no offer, the page exactly as it was.
const agent = String(DesktopPreferences.get("preferredAgent") ?? "none");
return agent !== "" && agent !== "none";
}
// `repairFailed` is the row's own answer rather than a second computation
// of it here: the status text beside the button already says "Repair
// failed", and two independent readings of one fact is how a button starts
// disagreeing with the words next to it.
function canAskAgent(check: var, repairFailed: bool): bool {
if (!root.agentHandoffAvailable || !check || check.status !== "error")
return false;
return check.action?.kind !== "repair" || repairFailed === true;
}
// Built from the snapshot this page already holds rather than by shelling
// the doctor a second time: these are the same fields `panama doctor check
// <id>` returns, and asking twice would only create a way for the two to
// disagree. The agent is handed that command anyway, so its first move is
// a fresh reading rather than trust in ours.
function agentPrompt(check: var, repairFailed: bool): string {
const lines = [
"A System Health check on this Panama machine is red and I want to know why.",
"",
"What panama doctor reported:",
" check: " + String(check.id) + " (" + String(check.group) + ")",
" title: " + String(check.title),
" status: " + String(check.status),
" detail: " + String(check.detail ?? "")
];
if (check.repairCommand)
lines.push(" repair: " + String(check.repairCommand)
+ (repairFailed ? " — run, and it failed" : " — offered, not yet run"));
else
lines.push(" repair: none offered");
lines.push("");
lines.push("Start with `panama doctor check " + String(check.id) + "` for the current");
lines.push("snapshot, then find the cause: the journal first, then whether a recent");
lines.push("package update or configuration change explains it.");
lines.push("");
lines.push("Diagnosis reads; it does not fix. Anything needing root goes through");
lines.push("`panama-sudo --reason \"why\" -- <command>`, so the password prompt says why.");
return lines.join("\n");
}
function askAgent(check: var, repairFailed: bool): void {
if (!root.canAskAgent(check, repairFailed))
return;
// Reached by path rather than by name: the shell is started by systemd,
// whose environment does not carry the repository's bin directory on
// PATH. Same expansion the panama-crash-watch unit uses.
Quickshell.execDetached(["sh", "-c",
'"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-agent" --prompt '
+ root.shellQuote(root.agentPrompt(check, repairFailed))]);
}
// POSIX single-quoting: everything between the quotes is literal, and the
// only character needing care is the quote itself. A check's detail is
// helper output, not a command, and this keeps it that way.
function shellQuote(text: string): string {
return "'" + String(text).replace(/'/g, "'\\''") + "'";
}
function checksForGroup(group: string): var {
return Health.checks.filter(check => check.group === group
&& (check.status === "ok" || check.status === "unconfigured"));
@@ -160,7 +239,12 @@ SettingsPage {
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
const agentHandoffs = root.descendants(root, "health-ask-agent:").filter(button => button.visible);
return {
// Empty whenever no agent is chosen, which is the shipped default
// and the state this page has to keep behaving exactly as it did.
agentHandoffs: agentHandoffs.map(button =>
String(button.objectName).slice("health-ask-agent:".length)),
renderedRows: rows.map(row => {
const objectName = String(row.objectName);
const parts = objectName.split(":");
@@ -295,16 +379,52 @@ SettingsPage {
id: issueRepeater
model: root.issueChecks
HealthCheckRow {
// The row plus, where the check has run out of answers,
// the handoff button beside it. The row keeps its own
// layout and yields the width the button takes, so the
// trailing controls never stack on top of each other.
Item {
id: issueEntry
required property var modelData
required property int index
readonly property bool offersAgent:
root.canAskAgent(issueEntry.modelData, issueRow.repairFailed)
width: issueRows.width
check: modelData
detailText: root.repairDetail(modelData)
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
implicitHeight: issueRow.implicitHeight
HealthCheckRow {
id: issueRow
width: issueEntry.width
- (issueEntry.offersAgent ? askAgentButton.width + 12 : 0)
check: issueEntry.modelData
detailText: root.repairDetail(issueEntry.modelData)
issue: true
divider: issueEntry.index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
SettingsButton {
id: askAgentButton
objectName: `health-ask-agent:${issueEntry.modelData.id}`
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: issueEntry.offersAgent
text: "Ask the agent"
enabled: visible && !Health.busy
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.askAgent(issueEntry.modelData, issueRow.repairFailed)
Keys.onReturnPressed: if (enabled)
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
Keys.onSpacePressed: if (enabled)
root.askAgent(issueEntry.modelData, issueRow.repairFailed)
}
}
}
}
@@ -295,6 +295,7 @@ the owner instead of duplicating it.
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `showAgentUsage` | Bar | Agents | Bar decides what the bar contains; Agents holds the collectors and interval the switch governs, and a card you cannot turn off from itself is not a card. |
Lock-screen visuals belong only to **Appearance**: background source, blur,
clock, date, user name, and password-field presentation. **Power** owns when
@@ -203,6 +203,7 @@ Rectangle {
case "containers": return containersPage;
case "ssh-keys": return sshKeysPage;
case "services": return healthPage;
case "agents": return agentsPage;
case "manual": return manualPage;
case "about": return aboutPage;
default: return homePage;
@@ -275,6 +276,7 @@ Rectangle {
Component { id: privacyPage; PrivacyPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} }
Component { id: agentsPage; AgentsPage {} }
Component { id: manualPage; ManualPage {} }
Component { id: aboutPage; AboutPage {} }
@@ -29,6 +29,7 @@ FocusAllowChips 1.0 FocusAllowChips.qml
PasswordRow 1.0 PasswordRow.qml
PrintersPage 1.0 PrintersPage.qml
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
AgentsPage 1.0 AgentsPage.qml
HealthPage 1.0 HealthPage.qml
HealthSummary 1.0 HealthSummary.qml
HealthCheckRow 1.0 HealthCheckRow.qml
@@ -1,118 +0,0 @@
#!/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 '
# The endpoint reports utilisation as a percentage already -- 15 means 15%.
# This multiplied by 100 on the assumption it was a 0..1 fraction, which is
# how the bar came to read 1500%. Clamped as well as rounded, because a
# readout is a number you glance at and trust; one that can exceed 100
# teaches you not to.
def pct: if type == "number" then ([[(. | round), 0] | max, 100] | min) 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"
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
"""Print one display-ready Claude Code usage record as JSON.
Everything the usage panel shows for Claude comes from here: local transcript
statistics from the Claude Code projects directory, the stats-cache and history
fallbacks for machines with no transcripts, and the authoritative rate limits
from Anthropic's OAuth usage endpoint. The panel reads only the JSON this
prints; it never learns a disk format or an endpoint shape.
Adapted from Omarchy (bin/omarchy-agent-usage-claude).
Copyright (c) David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
── What this deliberately does NOT do ────────────────────────────────────────
It never refreshes the OAuth token, and it never writes to the credentials file.
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 signed out of Claude Code by a status widget. No bar
indicator is worth that. So this reads the token, uses it while it is valid, and
reports an honest waiting state when it is not.
── The token ─────────────────────────────────────────────────────────────────
It never reaches argv. The request is made in this process with urllib, so there
is no child process whose /proc/<pid>/cmdline could carry a credential -- the
same rule panama-pick follows for passwords and panama-sudo for the MOK hash,
kept by having no subprocess at all rather than by hiding an argument.
It never reaches the output either. The record below carries percentages,
timestamps and token counts; the only thing from the credential store that may
travel into it is the display-safe plan label.
── Seams ─────────────────────────────────────────────────────────────────────
CLAUDE_CONFIG_DIR Claude Code's config directory (~/.claude)
PANAMA_AGENT_CREDENTIALS the credentials file, for tests
PANAMA_AGENT_USAGE_ENDPOINT the usage endpoint, for tests
PANAMA_AGENT_USAGE_CACHE the scan/probe cache directory
The output path is not a seam here: this prints, and panama-agent-usage-update
owns the atomic write into $XDG_STATE_HOME/panama/agents/usage/.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
AGENT_ID = "claude"
AGENT_NAME = "Claude Code"
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
# A panel that is opened and shut repeatedly must not turn into a request per
# flick, so a recent probe result is reused for this long.
PROBE_MIN_INTERVAL_SECONDS = 15
# A normal run reuses a scan only long enough to dedup concurrent collectors
# (the fan-out backs one off per agent). --limits-only promises fresh limits and
# nothing else, so it may reuse a scan for far longer.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value)))
def config_dir() -> Path:
return expand_path(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude")
def credentials_path(claude_dir: Path) -> Path:
override = os.environ.get("PANAMA_AGENT_CREDENTIALS")
return expand_path(override) if override else claude_dir / ".credentials.json"
def endpoint() -> str:
return os.environ.get("PANAMA_AGENT_USAGE_ENDPOINT") or DEFAULT_ENDPOINT
def cache_root() -> Path:
override = os.environ.get("PANAMA_AGENT_USAGE_CACHE")
root = expand_path(override) if override else (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "panama" / "agent-usage"
)
root.mkdir(parents=True, exist_ok=True)
return root
def date_string(value: dt.date) -> str:
return value.strftime("%Y-%m-%d")
def recent_date_strings() -> list[str]:
today = dt.datetime.now().date()
return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
def local_date_string() -> str:
return date_string(dt.datetime.now().date())
def local_date_from_timestamp(value: Any) -> str:
if value is None:
return local_date_string()
if isinstance(value, (int, float)):
try:
seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
return date_string(dt.datetime.fromtimestamp(seconds).date())
except Exception:
return local_date_string()
raw = str(value).strip()
if not raw:
return local_date_string()
# Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but
# not a trailing Z until it is normalized to +00:00.
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return date_string(parsed.date())
except Exception:
return local_date_string()
def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int:
value = usage.get(snake_key, usage.get(camel_key, 0))
try:
return round(float(value or 0))
except Exception:
return 0
def number(value: Any) -> int:
try:
n = float(value or 0)
return round(n) if n == n else 0
except Exception:
return 0
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
# ─────────────────────────────────────────────────────────────── local scan ──
def scan_projects(projects_path: Path) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing keeps files with
# unrelated lines inexpensive.
if '"usage":' not in line:
continue
try:
entry = json.loads(line)
except Exception:
continue
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
# One assistant message can be written more than once (a
# resumed session replays it). The message id is what tells
# a replay from a second answer.
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else (
f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
)
if unique_key in seen:
continue
seen.add(unique_key)
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# recentDays.messageCount is a token total despite the
# legacy name; the panel draws it as one.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"panama-agent-usage-claude: ignoring unreadable {path}: {exc}", file=sys.stderr)
return {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]:
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache's freshness cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path: Path, payload: dict[str, Any]) -> None:
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once (the fan-out backgrounds one per agent), and a
# shared temp path means the second replace finds the first one's file
# already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
# mkstemp opens at 0600; nothing in this cache is a secret.
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
"""Local stats, with the cache as a pure optimization.
A cache-layer failure (unwritable cache root, lock errors, a full disk) must
never take the collector down: it degrades to a direct scan. The printed
record is the contract; the cache is not.
"""
try:
cache_file, lock_file = scan_cache_paths(projects_path)
cached = read_cached_scan(cache_file, max_age_seconds)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_scan(cache_file, max_age_seconds)
if cached is not None:
return cached
summary = scan_projects(projects_path)
write_json(cache_file, {"schemaVersion": 1, "scanDate": local_date_string(), "stats": summary})
return summary
except Exception as exc:
print(f"panama-agent-usage-claude: cache unavailable ({exc}); scanning directly", file=sys.stderr)
return scan_projects(projects_path)
# The cache payload is a versioned envelope around the stats dict, so a
# corrupted or foreign-shaped file is a miss (rescan and rewrite) rather than a
# crash or a garbage record.
def read_cached_scan(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None:
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned. A cache from
# another local date (midnight passed, or the clock moved) is a miss, not
# merely old, whatever its mtime says.
if cached.get("scanDate") != local_date_string():
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
# ────────────────────────────────────────────────────────── local fallback ──
#
# A machine without transcripts on disk can still know its history: Claude Code
# keeps aggregate counters in stats-cache.json and per-prompt history in
# history.jsonl. Only consulted when the project scan comes back empty.
def stats_cache_fallback(claude_dir: Path) -> dict[str, Any] | None:
try:
data = json.loads((claude_dir / "stats-cache.json").read_text(encoding="utf-8"))
except Exception:
return None
today = local_date_string()
daily_model_tokens = data.get("dailyModelTokens") or []
today_tokens = {}
for entry in daily_model_tokens:
if isinstance(entry, dict) and entry.get("date") == today:
today_tokens = entry.get("tokensByModel") or {}
break
daily_activity = [day for day in (data.get("dailyActivity") or []) if isinstance(day, dict)]
active_dates = sorted({
str(day.get("date")) for day in daily_activity
if number(day.get("messageCount")) > 0 and day.get("date")
})
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
return {
"todayPrompts": today_prompts,
"todaySessions": today_sessions,
"todayTotalTokens": sum(number(v) for v in today_tokens.values()),
"todayTokensByModel": today_tokens,
"recentDays": daily_activity[-7:],
"modelUsage": data.get("modelUsage") or {},
"totalPrompts": number(data.get("totalMessages")),
"totalSessions": number(data.get("totalSessions")),
"activeDays": len(active_dates),
"activeDates": active_dates,
}
def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
prompts = 0
sessions: set[str] = set()
start_of_day = dt.datetime.combine(dt.datetime.now().date(), dt.time.min).timestamp() * 1000
try:
with (claude_dir / "history.jsonl").open("r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
except Exception:
return 0, 0
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except Exception:
continue
if number(entry.get("timestamp")) < start_of_day:
break
prompts += 1
if entry.get("sessionId"):
sessions.add(str(entry.get("sessionId")))
return prompts, len(sessions)
# ───────────────────────────────────────────────────────────────── limits ──
# The access token, its expiry, and the display-safe plan label from the CLI's
# login. Nothing else leaves the credential store: the token goes nowhere but
# the Authorization header of the limits probe, and only the plan label may
# travel into the printed record.
def oauth_login(credentials: Path) -> tuple[str, int, str]:
try:
data = json.loads(credentials.read_text(encoding="utf-8"))
except Exception:
return "", 0, ""
login = data.get("claudeAiOauth")
if not isinstance(login, dict):
return "", 0, ""
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
def plan_label(tier: str, subscription: str) -> str:
if tier:
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def parse_utilization(value: Any) -> float:
try:
return float(str(value).strip().replace("%", ""))
except Exception:
return float("nan")
def normalize_utilization(value: Any, percent_scale: bool) -> float:
n = parse_utilization(value)
if not (n >= 0):
return -1.0
# The OAuth usage endpoint currently reports percentages (37.0, or 1.0).
# Older payloads sometimes used fractions (0.37). A payload containing any
# value >= 1 is percent-scaled, so 1.0 renders as 1%, not 100%. Clamped as
# well as normalized: a readout that can print 1500% is one you learn to
# ignore.
if percent_scale or n > 1:
return min(1.0, n / 100.0)
return min(1.0, n)
def normalize_reset_at(value: Any) -> str:
if value is None:
return ""
raw = str(value).strip()
if raw == "":
return ""
if raw.isdigit():
ts = int(raw)
if ts < 1e12:
ts *= 1000
try:
return dt.datetime.fromtimestamp(ts / 1000, dt.timezone.utc).isoformat()
except Exception:
return raw
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
return parsed.isoformat()
except Exception:
return raw
def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None:
bucket = payload.get(key)
return bucket if isinstance(bucket, dict) else None
# An entry's `kind` names its window the way the flat buckets' keys do
# ("weekly_scoped", "five_hour_scoped"). Reading a window out of free text
# cannot survive a model name like "Opus 5 (1M context)" -- the "1M" reads as a
# one-minute window -- so the window is settled here and travels as an explicit
# title, capitalized the way the flat windows title themselves so "Fable Weekly"
# sits beside "Weekly" rather than under it.
def scoped_window(kind: str) -> str:
text = kind.lower()
if "month" in text:
return "Monthly"
if "week" in text or "day" in text:
return "Weekly"
if "hour" in text or "session" in text:
return "Session"
return ""
# Alongside the flat buckets the payload carries a `limits` array, and that
# array is the only place a model-scoped allowance shows up -- a weekly window
# only one model draws from, say. The matching legacy keys (`seven_day_opus`,
# `seven_day_sonnet`, ...) stayed behind at null, so a collector reading buckets
# alone silently drops a limit the account is actually spending against. A model
# can hold more than one scoped window, and only the pair of model and window
# tells them apart, so both make the title and both make the dedupe key.
def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]:
entries = payload.get("limits")
if not isinstance(entries, list):
return []
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
model = scope.get("model") if isinstance(scope, dict) else None
if not isinstance(model, dict):
continue
# A display name is what the panel wants, but an entry carrying only an
# id still names a window worth showing.
name = str(model.get("display_name") or model.get("id") or "").strip()
kind = str(entry.get("kind") or "").strip()
if name == "" or (name, kind) in seen:
continue
percent = normalize_utilization(entry.get("percent"), percent_scale)
if percent < 0:
continue
seen.add((name, kind))
window = scoped_window(kind)
title = name + " " + window if window else name
out.append({
"label": title,
"percent": percent,
"resetsAt": normalize_reset_at(entry.get("resets_at")),
})
return out
def probe_limits(access_token: str) -> dict[str, Any]:
# The token travels in a header on a request made in this process. There is
# no child process, so there is no argv to leak it through.
request = urllib.request.Request(
endpoint(),
headers={
"Authorization": "Bearer " + access_token,
"anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as error:
retry_after = error.headers.get("retry-after", "") if error.headers else ""
if error.code == 429:
help_text = "Anthropic's usage endpoint is rate limiting checks right now" + (
f" (retry after {retry_after}s)" if retry_after else ""
) + ". Local Claude Code stats are still shown."
else:
help_text = (
f"Anthropic's usage endpoint returned status {error.code}. "
"Local Claude Code stats are still shown."
)
return {"ok": False, "helpText": help_text}
except Exception:
# A transport failure reached no server at all -- no route, no DNS. Any
# real answer, including an error status, is a server worth not
# pestering; this is not.
return {
"ok": False,
"transport": True,
"helpText": "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown.",
}
if not isinstance(payload, dict):
return {"ok": False, "helpText": "Anthropic's usage endpoint returned an unfamiliar shape."}
weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day")
session = usage_bucket(payload, "five_hour")
raw = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None]
# One payload speaks one convention, so the scoped entries settle the scale
# alongside the buckets rather than assuming their own.
entries = payload.get("limits")
if isinstance(entries, list):
raw += [entry.get("percent") for entry in entries if isinstance(entry, dict)]
percent_scale = any(parse_utilization(v) >= 1 for v in raw)
limits = []
if session is not None:
percent = normalize_utilization(session.get("utilization"), percent_scale)
if percent >= 0:
limits.append({
"label": "Session (5-hour)",
"percent": percent,
"resetsAt": normalize_reset_at(session.get("resets_at")),
})
if weekly is not None:
percent = normalize_utilization(weekly.get("utilization"), percent_scale)
if percent >= 0:
limits.append({
"label": "Weekly (7-day)",
"percent": percent,
"resetsAt": normalize_reset_at(weekly.get("resets_at")),
})
limits.extend(scoped_limits(payload, percent_scale))
if not limits:
return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown."}
return {"ok": True, "limits": limits}
# A cached percentage outlives the probe that measured it, but only until its
# window rolls over: once a window has reset the figure describes a period that
# is over, and a stale 78% would misreport an allowance that is now untouched. A
# window with no reset time, or one that will not parse, is kept -- an
# unreadable timestamp is no reason to throw away a real number.
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
raw = str(entry.get("resetsAt") or "")
if raw == "":
return True
try:
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return True
if resets_at.tzinfo is None:
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
return resets_at > now
def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
entries = cached.get("limits")
if not isinstance(entries, list):
return []
now = dt.datetime.now(dt.timezone.utc)
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
result: dict[str, Any] = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
probe_cache = cache_root() / "claude-limits.json"
cached = read_fresh_json(probe_cache, float("inf")) or {}
fallback = usable_cached_limits(cached)
# Probing needs a live token and only the Claude Code CLI can mint one: it
# refreshes the credential file when it runs, so a machine left alone long
# enough finds the saved token lapsed. Say so -- an empty limits list with
# nothing else set hides the whole section and explains nothing -- and keep
# showing the last numbers whose window has not since reset.
if access_token == "":
result["limits"] = fallback
result["usageStatusText"] = "Waiting for auth"
return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
result["limits"] = fallback
result["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = (
"Claude Code's saved sign-in expired"
+ (" — showing the last known limits." if fallback else ".")
+ " Start Claude Code, or run `claude auth login`, to refresh it."
)
return result
# --force is a person asking for fresh numbers, so it skips the reuse window
# entirely; the interval absorbs repeated panel opens, it does not overrule
# someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback
return result
probe = probe_limits(access_token)
if probe["ok"]:
result["limits"] = probe["limits"]
try:
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
except Exception as exc:
print(f"panama-agent-usage-claude: could not cache limits ({exc})", file=sys.stderr)
return result
# The first probe after login often fires before DHCP has handed out a
# route. Ask the shell to try again sooner than its regular interval.
if probe.get("transport"):
result["retryAdvised"] = True
if fallback:
result["limits"] = fallback
else:
result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"]
return result
# ───────────────────────────────────────────────────────────────── record ──
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Claude Code usage record as JSON")
parser.add_argument("--force", action="store_true",
help="rescan transcripts and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true",
help="reuse any recent transcript scan; only the limits probe must be fresh")
args = parser.parse_args()
claude_dir = config_dir()
scan_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = cached_scan(claude_dir / "projects", scan_age)
if number(stats.get("totalPrompts")) <= 0:
fallback = stats_cache_fallback(claude_dir)
if fallback is not None:
stats = fallback
else:
# No transcripts and no aggregate cache, but history.jsonl alone can
# still put numbers on today.
today_prompts, today_sessions = today_prompts_from_history(claude_dir)
if today_prompts or today_sessions:
stats = dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions)
access_token, expires_at_ms, plan = oauth_login(credentials_path(claude_dir))
limits = collect_limits(access_token, expires_at_ms, args.force)
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
"ready": number(stats.get("totalPrompts")) > 0 or len(limits["limits"]) > 0,
"hasLocalStats": True,
"tierLabel": plan,
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"limits": limits["limits"],
}
if limits.get("retryAdvised"):
record["retryAdvised"] = True
record.update(stats)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+535
View File
@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""Print one display-ready Codex usage record as JSON.
Local statistics come from the Codex CLI's own session files; the plan and the
rate limits come from the Codex app-server over JSON-RPC. The usage panel reads
only the JSON this prints.
Adapted from Omarchy (bin/omarchy-agent-usage-codex).
Copyright (c) David Heinemeier Hansson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
── Secrets ───────────────────────────────────────────────────────────────────
This never reads the Codex credential store. The app-server is asked for the
account and its limits over a pipe, and it authenticates itself; no token
reaches this process, its argv, or the record. The app-server is spawned
read-only and untrusted so a usage query can never change anything.
── Seams ─────────────────────────────────────────────────────────────────────
CODEX_HOME Codex's home directory (~/.codex)
PANAMA_AGENT_CODEX_BIN the codex binary, for tests
PANAMA_AGENT_USAGE_CACHE the session-scan cache directory
The output path is not a seam here: this prints, and panama-agent-usage-update
owns the atomic write into $XDG_STATE_HOME/panama/agents/usage/.
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import select
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
AGENT_ID = "codex"
AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate."
# A scan this recent is only reused to dedup concurrent collector runs (the
# fan-out backgrounds one per agent). --limits-only promises only fresh limits,
# so it may reuse a scan for far longer.
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
# Sessions older than this are not what anyone is looking at, and walking them
# on every refresh is the difference between a scan and a stall.
SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value)))
def codex_home() -> Path:
return expand_path(os.environ.get("CODEX_HOME") or "~/.codex")
def runtime_env() -> dict[str, str]:
# Codex is commonly a user-level npm or mise install, and a collector run
# from the shell's environment does not always inherit those directories.
home = str(Path.home())
path_parts = [
os.environ.get("PATH", ""),
f"{home}/.local/bin",
f"{home}/.npm-global/bin",
f"{home}/.local/share/mise/shims",
]
env = os.environ.copy()
env["PATH"] = os.pathsep.join(part for part in path_parts if part)
return env
ENV = runtime_env()
def find_codex() -> str | None:
override = os.environ.get("PANAMA_AGENT_CODEX_BIN")
if override:
return override if os.access(override, os.X_OK) else None
return shutil.which("codex", path=ENV.get("PATH"))
def cache_root() -> Path:
override = os.environ.get("PANAMA_AGENT_USAGE_CACHE")
root = expand_path(override) if override else (
Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "panama" / "agent-usage"
)
root.mkdir(parents=True, exist_ok=True)
return root
def number(value: Any) -> int:
try:
return int(value or 0)
except Exception:
return 0
def model_name(raw: Any) -> str:
value = str(raw or "codex")
return value if value else "codex"
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
def local_day(value: Any) -> str:
if value is None:
return today
if isinstance(value, (int, float)):
# Codex timestamps are usually seconds; anything larger is milliseconds.
if value > 10_000_000_000:
value = value / 1000
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
text = str(value)
try:
parsed = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return parsed.strftime("%Y-%m-%d")
except Exception:
return today
class Tally:
"""Everything the session scan accumulates, in one place."""
def __init__(self) -> None:
self.recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
self.today_tokens_by_model: dict[str, int] = {}
self.model_usage: dict[str, dict[str, int]] = {}
self.today_sessions: set[str] = set()
self.total_sessions: set[str] = set()
self.active_days: set[str] = set()
self.today_prompts = 0
self.today_total_tokens = 0
self.total_prompts = 0
def add(self, day: str, session_key: str, model: str,
input_tokens: int, output_tokens: int, cache_read: int, cache_write: int) -> None:
total = input_tokens + output_tokens + cache_read + cache_write
self.total_prompts += 1
self.total_sessions.add(session_key)
self.active_days.add(day)
bucket = self.model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in self.recent:
# recentDays.messageCount is a token total despite the legacy name.
self.recent[day]["messageCount"] += total
if day == today:
self.today_prompts += 1
self.today_sessions.add(session_key)
self.today_total_tokens += total
self.today_tokens_by_model[model] = self.today_tokens_by_model.get(model, 0) + total
def stats(self) -> dict[str, Any]:
return {
"todayPrompts": self.today_prompts,
"todaySessions": len(self.today_sessions),
"todayTotalTokens": self.today_total_tokens,
"todayTokensByModel": self.today_tokens_by_model,
"recentDays": [self.recent[day] for day in recent_dates],
"totalPrompts": self.total_prompts,
"totalSessions": len(self.total_sessions),
"activeDays": len(self.active_days),
"activeDates": sorted(self.active_days),
"modelUsage": self.model_usage,
}
def scan_native_sessions(tally: Tally) -> None:
home = codex_home()
roots = [home / "sessions", home / "archived_sessions"]
files = []
cutoff = time.time() - SESSION_MAX_AGE_SECONDS
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError:
pass
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(
payload.get("model") or payload.get("model_slug") or current_model
)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding
# every snapshot makes usage grow quadratically, so only the
# last turn is counted.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens and reasoning
# tokens in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
tally.add(day, str(path), current_model,
input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
# ───────────────────────────────────────────────────────────── scan cache ──
def scan_cache_paths() -> tuple[Path, Path]:
digest = hashlib.sha1(str(codex_home()).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
def read_fresh_json(path: Path, max_age_seconds: float) -> Any:
if max_age_seconds <= 0 or not path.exists():
return None
try:
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return None
def write_json(path: Path, payload: dict[str, Any]) -> None:
# A temp name unique to this writer, not derived from the target: several
# collectors can run at once, and a shared temp path means the second
# replace finds the first one's file already moved away.
handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
tmp.chmod(0o644)
tmp.replace(path)
except BaseException:
tmp.unlink(missing_ok=True)
raise
# The cache payload is a versioned envelope around the stats dict, so a
# corrupted or foreign-shaped file is a miss (rescan and rewrite) rather than a
# crash or a garbage record.
def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None:
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
return None
# today* fields only mean "today" on the day they were scanned.
if cached.get("scanDate") != today:
return None
stats = cached.get("stats")
if not isinstance(stats, dict):
return None
if not all(key in stats for key in ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage")):
return None
return stats
def local_stats(max_age: float) -> dict[str, Any]:
"""Local stats, with the cache as a pure optimization.
A cache-layer failure must never take the collector down: it degrades to a
direct scan. The printed record is the contract; the cache is not.
"""
try:
cache_file, lock_file = scan_cache_paths()
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
tally = Tally()
scan_native_sessions(tally)
stats = tally.stats()
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
return stats
except Exception as exc:
print(f"panama-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
tally = Tally()
scan_native_sessions(tally)
return tally.stats()
# ──────────────────────────────────────────────────────────────── app-server ──
def rpc_request(proc: subprocess.Popen, request_id: int, method: str,
params: dict[str, Any] | None = None, timeout: float = 8) -> dict[str, Any]:
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
def limit_window(window: Any, prefix: str = "") -> dict[str, Any] | None:
if not isinstance(window, dict):
return None
used = window.get("usedPercent")
if used is None:
return None
mins = number(window.get("windowDurationMins"))
if mins == 10080:
label = "Weekly (7-day)"
elif mins == 300:
label = "Session (5-hour)"
elif mins and mins % 60 == 0:
label = f"{mins // 60}h window"
elif mins:
label = f"{mins}m window"
else:
label = "Limit"
if prefix:
label = f"{prefix} {label}"
reset = window.get("resetsAt")
try:
percent = min(1.0, max(0.0, float(used) / 100.0))
except Exception:
return None
return {
"label": label,
"percent": percent,
"resetsAt": datetime.fromtimestamp(number(reset), timezone.utc).isoformat() if reset else "",
}
# The account's own windows come back under `primary` and `secondary`.
# `rateLimitsByLimitId` repeats those under the account's limit id and adds the
# model-scoped ones beside them -- a window only one model draws from, named by
# `limitName`. An entry with no name is the account limit again, so only the
# named ones are worth a row of their own.
def scoped_limits(limits: dict[str, Any]) -> list[dict[str, Any]]:
by_id = limits.get("rateLimitsByLimitId")
if not isinstance(by_id, dict):
return []
out: list[dict[str, Any]] = []
for entry in by_id.values():
if not isinstance(entry, dict):
continue
name = str(entry.get("limitName") or "").strip()
if name == "":
continue
for window in (entry.get("primary"), entry.get("secondary")):
row = limit_window(window, name)
if row:
out.append(row)
return out
def fetch_rpc() -> dict[str, Any]:
result: dict[str, Any] = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
codex = find_codex()
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex was not found on PATH."
return result
try:
# Read-only, and never asking for approval: a usage query has no
# business being able to change anything on this machine, and nothing is
# watching a prompt it might raise. (`-a untrusted` was the flag Omarchy
# used; codex 0.149 takes on-request or never.)
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "never", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=ENV,
)
except Exception as exc:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize",
{"clientInfo": {"name": "panama-agent-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
account = (account_msg.get("result") or {}).get("account") or {}
payload = limits_msg.get("result") or {}
limits = payload.get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["tierLabel"] = str(plan) if plan else ""
for window in (limits.get("primary"), limits.get("secondary")):
entry = limit_window(window)
if entry:
result["limits"].append(entry)
result["limits"].extend(scoped_limits(payload))
except TimeoutError as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = (
f"The Codex app-server did not answer `{exc}` in time. "
"Start Codex once, or run `codex login`, and the limits come back."
)
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc) or "The Codex app-server could not be reached."
finally:
try:
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
return result
# ───────────────────────────────────────────────────────────────── record ──
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Codex usage record as JSON")
parser.add_argument("--force", action="store_true",
help="rescan sessions and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true",
help="reuse any recent session scan; only the limits probe must be fresh")
args = parser.parse_args()
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = local_stats(max_age)
rpc = fetch_rpc()
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
# Honest about having nothing to say: a Codex that is not signed in and
# has never run leaves the panel's tab empty rather than showing zeros.
"ready": number(stats.get("totalPrompts")) > 0 or len(rpc["limits"]) > 0,
"hasLocalStats": True,
}
record.update(stats)
record.update(rpc)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Refresh the agent usage records the bar panel watches.
#
# Each panama-agent-usage-<agent> collector prints one display-ready JSON
# record; this runs the enabled ones in parallel and writes their output to
# $XDG_STATE_HOME/panama/agents/usage/<agent>.json. Adding an agent is adding a
# collector -- the panel picks up any record that appears in that directory, and
# no QML changes.
#
# Adapted from Omarchy (bin/omarchy-agent-usage-update).
#
# Copyright (c) David Heinemeier Hansson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# ── Why this reads settings.json rather than being told ──────────────────────
#
# Per-agent collection is a preference, and a preference is read at the moment
# it matters -- the same rule the power button follows. A collector the user
# turned off must not run at all: it is the thing that reads a credential file
# and makes a network call, so "off" has to mean "no process", not "a process
# whose output is discarded".
#
# ── Why a disabled agent's record is deleted ─────────────────────────────────
#
# The panel watches the directory. Leaving yesterday's record behind after the
# collector is switched off would keep showing numbers nothing is refreshing,
# which is worse than an empty tab.
set -uo pipefail
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Seams. The contract points all three somewhere hermetic; nothing else does.
COLLECTOR_DIR="${PANAMA_AGENT_USAGE_COLLECTORS:-$self_dir}"
USAGE_DIR="${PANAMA_AGENT_USAGE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/panama/agents/usage}"
SETTINGS="${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
command -v jq >/dev/null 2>&1 || {
printf 'panama-agent-usage-update: jq is required\n' >&2
exit 1
}
mkdir -p "$USAGE_DIR" || exit 1
flags=()
only=()
while (( $# > 0 )); do
case "$1" in
--force | --limits-only) flags+=("$1") ;;
--help | -h)
printf 'usage: panama-agent-usage-update [--force] [--limits-only] [agent...]\n'
exit 0
;;
-*)
printf 'panama-agent-usage-update: unknown option %s\n' "$1" >&2
exit 2
;;
*) only+=("$1") ;;
esac
shift
done
# `.key // true` is wrong here: jq's alternative operator fires on false as well
# as on null, so a collector the user switched off would read as enabled. Absent
# is the only case that means "use the default".
enabled() {
local agent="$1" key answer
key="agentUsage$(tr '[:lower:]' '[:upper:]' <<<"${agent:0:1}")${agent:1}"
[[ -r "$SETTINGS" ]] || return 0
answer="$(jq -r --arg key "$key" \
'if has($key) then (.[$key] | tostring) else "true" end' "$SETTINGS" 2>/dev/null)" || return 0
[[ "$answer" == "true" ]]
}
requested() {
local agent="$1" candidate
(( ${#only[@]} == 0 )) && return 0
for candidate in "${only[@]}"; do
[[ "$candidate" == "$agent" ]] && return 0
done
return 1
}
# A record is replaced only by a whole, parseable one. A collector that dies
# halfway, or prints a stack trace, leaves the previous numbers standing rather
# than blanking the panel: mktemp + mv makes the swap atomic, so a reader never
# sees a half-written file, and the jq gate makes sure the thing being moved
# into place is a record at all.
collect() {
local collector="$1" agent="$2" record tmp
if ! record="$("$collector" "${flags[@]}" 2>/dev/null)" \
|| [[ -z "$record" ]] \
|| ! jq -e . >/dev/null 2>&1 <<<"$record"; then
printf 'panama-agent-usage-update: the %s collector produced no usable record\n' "$agent" >&2
return 1
fi
tmp="$(mktemp "$USAGE_DIR/.$agent.XXXXXX")" || return 1
printf '%s\n' "$record" >"$tmp" || { rm -f "$tmp"; return 1; }
chmod 644 "$tmp"
mv "$tmp" "$USAGE_DIR/$agent.json"
}
pids=()
status=0
for collector in "$COLLECTOR_DIR"/panama-agent-usage-*; do
[[ -x "$collector" ]] || continue
agent="${collector##*/panama-agent-usage-}"
[[ "$agent" == "update" ]] && continue
requested "$agent" || continue
if ! enabled "$agent"; then
rm -f "$USAGE_DIR/$agent.json"
continue
fi
collect "$collector" "$agent" &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
+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
}
+45
View File
@@ -109,6 +109,49 @@ Singleton {
return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : "";
}
// ── Commands carried as data ────────────────────────────────────────────
//
// A notification may name a shell command in the `panama-exec` hint, and
// clicking its body runs it (modules/notifications/NotificationCard.qml).
// That is how the escalation ladder works: `panama-crash-watch` sees a
// coredump, sends "click to diagnose with your agent", and exits. The
// sender does not have to stay alive to service a freedesktop action, and
// the click keeps working across a shell restart, because the command is
// the notification rather than a callback into a process that has gone.
//
// Kept beside `arrivals` and for the same reason: the protocol hands the
// value over once, at delivery, and the card needs it long afterwards. One
// read, one validation, one entry per notification, dropped by forget().
//
// SECURITY. Any process on this session bus can set this hint. That is not
// an escalation: a process that can reach the session bus can already run
// whatever it likes as this user, without asking a notification card
// first, so the hint grants nothing a local process lacks. The property
// this DOES keep is that nothing runs on arrival -- the command is stored,
// never executed here, and only a deliberate click on the card runs it.
readonly property var execCommands: ({})
// The command this notification carries, or "" for the overwhelming
// majority that carry none.
function execCommand(notification: var): string {
if (!notification)
return "";
const stored = root.execCommands[notification.id];
return typeof stored === "string" ? stored : "";
}
// Stamped at delivery. An id being reused by a replacement notification
// that carries no hint has to clear the old command rather than inherit
// it, which is why the empty case deletes instead of returning early.
function rememberExecCommand(notification: var): void {
const hints = notification.hints ?? {};
const command = String(hints["panama-exec"] ?? "").trim();
if (command === "")
delete root.execCommands[notification.id];
else
root.execCommands[notification.id] = command;
}
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
// and the no-display expiry a transient notification gets while Do Not
// Disturb is on (below). Critical urgency and an explicit expireTimeout
@@ -373,6 +416,7 @@ Singleton {
// Without this the object is destroyed the instant this returns.
notification.tracked = true;
root.arrivals[notification.id] = new Date();
root.rememberExecCommand(notification);
// The object may go away at any time (app-side close, dismiss()).
// Drop our references synchronously when it does.
@@ -610,6 +654,7 @@ Singleton {
// only ever removes references, never touches the notification.
function forget(n: var): void {
delete root.arrivals[n.id];
delete root.execCommands[n.id];
if (root.history.indexOf(n) !== -1)
root.history = root.history.filter(x => x !== n);
if (root.popups.indexOf(n) !== -1)
@@ -77,6 +77,7 @@ Singleton {
{ page: "about", label: "About" },
{ page: "updates", label: "Software Update" },
{ page: "services", label: "System Health" },
{ page: "agents", label: "Agents" },
{ page: "storage", label: "Storage" },
{ page: "snapshots", label: "Snapshots" },
{ page: "containers", label: "Containers" },
@@ -63,7 +63,11 @@ Singleton {
"capture": "screen-intelligence",
"gaming": "gaming",
"search": "applications",
"sound": "sound"
"sound": "sound",
// The escalation ladder and the usage collectors. `showAgentUsage`
// stays in the vitals group and keeps routing to Bar, which owns it;
// the Agents page mirrors that one switch and owns everything else.
"agents": "agents"
})
// Settings that are real but have no schema entry, because the system owns
@@ -342,6 +346,20 @@ Singleton {
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
// Agents. The schema names the switches, so these are the words people
// arrive with instead. "AI" and "assistant" matter most: neither is the
// label of any preference, and they are what somebody types to find out
// whether this desktop has any of that at all. The rest are the rungs
// of the escalation ladder, each of which is a thing that happens
// rather than a switch -- a crash notification you can click, a button
// that grows on a red health check, a panel behind the bar number.
{ label: "AI assistant", detail: "Choose the agent this desktop hands crashes, failed reloads and red health checks to", page: "agents" },
{ label: "Claude Code", detail: "Use Claude Code as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "Codex", detail: "Use Codex as the agent the desktop escalates to, and watch its usage", page: "agents" },
{ label: "Diagnose crashes", detail: "When a program dumps core, the notification carries a click that opens your AI assistant with the crash details", page: "agents" },
{ label: "Ask the agent", detail: "A red System Health check with no repair left hands its snapshot to your AI assistant", page: "agents" },
{ label: "Agent usage panel", detail: "Limits, resets and tokens for each agent, opened from the bar", page: "agents" },
{ label: "Agent permissions", detail: "Whether a launched assistant approves its own tools or asks the way it normally would", page: "agents" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance", section: "background" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance", section: "background" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance", section: "background" },
+71
View File
@@ -710,6 +710,77 @@ ShellRoot {
function onReloadFailed(error: string): void {
console.warn("Panama shell reload failed:", error);
Quickshell.inhibitReloadPopup();
root.offerReloadDiagnosis(error);
}
}
// ── The reload-failure rung of the escalation ladder ─────────────────────
//
// This is the failure most likely to happen while somebody is customizing
// the desktop, and the one they are least equipped to read: a QML parse
// error in the journal, and a shell that silently keeps running the old
// configuration. That "keeps running" is what makes the rung possible --
// the shell that failed to load the change is not the shell answering
// here, so it can still say so and still offer the log to an agent.
//
// Sent through notify-send rather than constructed in-process on purpose:
// it takes the same delivery path, the same per-application rules and the
// same `panama-exec` click as every other rung, so there is one mechanism
// to keep working rather than two.
//
// Everything below is guarded. A handler that throws on a failed reload
// turns one bad save into a broken desktop, so a fault in the offer must
// cost nothing beyond the offer.
function offerReloadDiagnosis(error: string): void {
try {
if (DesktopPreferences.get("reloadFailureOffer") !== true)
return;
// No agent chosen is the shipped default, and it means exactly
// what it says: the desktop stays quiet rather than volunteering a
// tool nobody asked for. The old actionless behaviour, verbatim.
const agent = String(DesktopPreferences.get("preferredAgent") ?? "none");
if (agent === "" || agent === "none")
return;
const spec = PreferenceSchema.spec("preferredAgent");
const option = (spec?.options ?? []).find(entry => entry.value === agent);
const label = option ? option.label : agent;
// The failing message goes to the launcher as ONE single-quoted
// argument, so nothing a QML parse error can contain -- and they
// contain plenty of punctuation -- ends the quoting and starts a
// command of its own. `panama-agent-reload` reads the rest of the
// context out of the journal itself.
const summary = String(error ?? "").replace(/\s+/g, " ").trim().slice(0, 500);
// The launcher is reached by path rather than by name: the shell
// is started by systemd, whose environment does not carry the
// repo's bin directory on PATH. Same expansion the
// panama-crash-watch unit uses.
const command = '"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-agent-reload" '
+ root.shellQuote(summary);
// Ordinary urgency, the same as the crash rung's. A critical
// notification never expires and can break through Do Not Disturb,
// which is a louder desktop than anybody asked for in exchange for
// an offer that keeps in history until it is read anyway.
Quickshell.execDetached([
"notify-send", "--app-name=Panama",
"--icon=dialog-error-symbolic",
"--hint=string:panama-exec:" + command,
"The shell could not reload your change",
"The previous configuration is still running. Click to hand the failure to "
+ label + "."
]);
} catch (problem) {
console.warn("Panama shell reload failure offer skipped:", problem);
}
}
// POSIX single-quoting: everything between the quotes is literal, and the
// only character that needs care is the quote itself.
function shellQuote(text: string): string {
return "'" + String(text).replace(/'/g, "'\\''") + "'";
}
}