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