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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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, "'\\''") + "'";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user