No error is a dead end: crash, click, and your agent is already looking

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 12:50:09 -04:00
parent ada0faf1d1
commit cc7d91d09c
43 changed files with 4648 additions and 327 deletions
@@ -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