Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/Resources.qml

Raw
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "sysinfo.js" as Sys
import "resources.js" as Res
import "system-problems.js" as Problems

// Thin I/O + cadence service over the pure sysinfo.js core.
// All parsing / issue-detection logic lives in sysinfo.js; this file only does
// file reads, process spawns, timers, and exposes reactive properties.
Item {
    id: root

    // --- injected services (DI via shell.qml; never singletons here) ---
    required property var upower // UPower singleton (displayDevice bindings)
    required property var powerProfiles // PowerProfiles singleton (degradationReason)
    required property Brightness brightnessService // Brightness.qml (identity row reuse)

    // --- view-bound live properties (fast loop) ---
    property real cpuUsage: 0
    property real memoryUsage: 0
    property real swapUsage: 0
    property real memTotal: 0
    property real memAvailable: 0
    property real swapTotal: 0
    property real swapFree: 0
    property real networkRxRate: 0
    property real networkTxRate: 0
    property real diskReadRate: 0
    property real diskWriteRate: 0
    // nullable: no sensor -> null (view shows n/a, thermal check skipped)
    property var cpuTemperatureC: null
    property var fanRpm: null
    property real loadOne: 0
    property real loadFive: 0
    property real loadFifteen: 0
    property int cores: 1

    // --- ring buffers for sparklines (cap = max samples a wide spark shows;
    // 96 × 2s ≈ 3 min of history) ---
    readonly property int ringCap: 96
    property var cpuRing: []
    property var memRing: []
    property var tempRing: []
    property var netRing: []
    property var netRxRing: [] // download, for the directional net spark
    property var netTxRing: [] // upload, for the directional net spark
    property var diskRing: []
    property var diskReadRing: []  // read, for the directional disk spark
    property var diskWriteRing: [] // write, for the directional disk spark

    // --- issue inputs (slow / rare loops) ---
    property var failedUnits: []
    property var incidents: []
    property string incidentStatus: "not checked"
    property string agentIssueId: ""
    property string agentError: ""
    property var pendingAgent: ({})
    signal agentDispatched
    property bool agentPromptLoading: false
    readonly property bool agentPending: agentPromptLoading || agentCheck.running
    property var diskUsage: [] // [{source,target,usedPercent}]
    property var updateCount: null // null = unknown / n/a
    property bool swapGrowing: false
    // thermal high threshold; resolved from sensor max/crit on start, default 87
    property real tempHighC: 87

    // aurora "firestorm" intensity, consumed by AuroraBackdrop.qml. A continuous
    // 0..1 ramp as CPU temp climbs the last 12°C toward the thermal threshold —
    // replaces the old hot/cool hysteresis counters. null temp => 0 (calm).
    readonly property real firestormTarget: {
        if (cpuTemperatureC === null || cpuTemperatureC === undefined)
            return 0;
        const t = Number(cpuTemperatureC);
        if (!isFinite(t))
            return 0;
        const high = Math.max(1, tempHighC);
        const cool = high - 12;
        return Math.max(0, Math.min(1, (t - cool) / (high - cool)));
    }

    // --- top processes (slow loop, only while popout open) ---
    property var processes: []

    // --- identity facts (rare loop, cached) ---
    property var identity: ({})
    property bool identityLoaded: false

    // --- popout visibility gate (set by the view) ---
    property bool popoutOpen: false

    // --- battery / throttle (event-driven UPower bindings) ---
    // healthPercentage is null when unsupported / no battery (NOT -1) so the
    // detectIssues battery check is skipped rather than firing a false error.
    // UPower percentage/healthPercentage are 0..1 fractions (energy/energyCapacity);
    // scale to 0..100 at this boundary so the view and detectIssues see whole percents.
    readonly property var healthPercentage: {
        const d = upower ? upower.displayDevice : null;
        return (d && d.ready && d.isLaptopBattery && d.healthSupported) ? Number(d.healthPercentage) * 100 : null;
    }
    readonly property bool isLaptopBattery: {
        const d = upower ? upower.displayDevice : null;
        return !!(d && d.ready && d.isLaptopBattery);
    }
    // live charge 0..100 (null when no battery) + a short state word
    readonly property var batteryPercent: {
        const d = upower ? upower.displayDevice : null;
        return (d && d.ready && d.isLaptopBattery) ? Math.round(Number(d.percentage) * 100) : null;
    }
    readonly property string batteryStatus: {
        const d = upower ? upower.displayDevice : null;
        if (!d || !d.ready || !d.isLaptopBattery)
            return "";

        switch (d.state) {
        case UPowerDeviceState.Charging:
        case UPowerDeviceState.PendingCharge:
            return "charging";
        case UPowerDeviceState.FullyCharged:
            return "full";
        case UPowerDeviceState.Discharging:
        case UPowerDeviceState.PendingDischarge:
            return "on battery";
        default:
            return "";
        }
    }
    readonly property var degradationReason: {
        if (!powerProfiles)
            return null;

        const r = powerProfiles.degradationReason;
        return (r === null || r === undefined) ? null : String(r);
    }

    // --- last issue action record (for IPC introspection) ---
    property var lastAction: ({})

    // --- internal delta state ---
    property var prevCpu: null
    property real prevNetRx: 0
    property real prevNetTx: 0
    property real prevDiskRead: 0
    property real prevDiskWrite: 0
    property real prevRateTime: 0
    property real prevSwapUsed: 0
    property var failedSystemUnits: []
    property var failedUserUnits: []

    // --- network-widget compatibility shims (consumed by shell.qml
    //     networkWidgetState(); out of scope for the system redesign, kept so a
    //     working widget is not silently regressed). ---
    property var networkHistorySamples: []

    function networkRxLevel() {
        return Sys.clamp01(networkRxRate / 262144);
    }

    function networkTxLevel() {
        return Sys.clamp01(networkTxRate / 262144);
    }

    function shaderHistoryObject(samples) {
        return {
            "windowSeconds": 7200,
            "sampleIntervalMs": 2000,
            "sampleLimit": 3600,
            "sampleCount": (samples || []).length
        };
    }

    // --- fast loop: pure /proc + /sys reads, deltas, rings (always on, 2s) ---
    function refreshFast() {
        statFile.reload();
        const statText = statFile.text() || "";
        const cd = Sys.cpuDelta(prevCpu, statText);
        cpuUsage = cd.usage;
        prevCpu = cd.next;
        const coreLines = statText.match(/^cpu[0-9]/gm);
        if (coreLines && coreLines.length > 0)
            cores = coreLines.length;

        meminfoFile.reload();
        const mem = Sys.parseMeminfo(meminfoFile.text() || "");
        if (mem.memTotal > 0) {
            memTotal = mem.memTotal;
            memAvailable = mem.memAvailable;
            swapTotal = mem.swapTotal;
            swapFree = mem.swapFree;
            memoryUsage = mem.memoryUsage;
            swapUsage = mem.swapUsage;
            const swapUsed = Math.max(0, mem.swapTotal - mem.swapFree);
            swapGrowing = swapUsed > prevSwapUsed + 1;
            prevSwapUsed = swapUsed;
        }

        netFile.reload();
        diskFile.reload();
        const net = Sys.parseNetDev(netFile.text() || "");
        const disk = Sys.parseDiskstats(diskFile.text() || "");
        const now = Date.now();
        const seconds = prevRateTime > 0 ? Math.max(0.2, (now - prevRateTime) / 1000) : 0;
        if (seconds > 0) {
            networkRxRate = Sys.rate(prevNetRx, net.rx, seconds);
            networkTxRate = Sys.rate(prevNetTx, net.tx, seconds);
            diskReadRate = Sys.rate(prevDiskRead, disk.readBytes, seconds);
            diskWriteRate = Sys.rate(prevDiskWrite, disk.writeBytes, seconds);
        }
        prevNetRx = net.rx;
        prevNetTx = net.tx;
        prevDiskRead = disk.readBytes;
        prevDiskWrite = disk.writeBytes;
        prevRateTime = now;

        loadFile.reload();
        const loadText = loadFile.text() || "";
        if (loadText.trim().length > 0) {
            const la = Sys.parseLoadavg(loadText);
            loadOne = la.one;
            loadFive = la.five;
            loadFifteen = la.fifteen;
        }

        // hwmon temp + fan; keep last-good on null/parse failure
        if (tempFile.path) {
            tempFile.reload();
            const t = Sys.parseTemp(tempFile.text() || "");
            if (t !== null)
                cpuTemperatureC = t;
        }
        if (fanFile.path) {
            fanFile.reload();
            const f = Sys.parseFan(fanFile.text() || "");
            if (f !== null)
                fanRpm = f;
        }

        cpuRing = Sys.pushRing(cpuRing, cpuUsage, ringCap);
        memRing = Sys.pushRing(memRing, memoryUsage, ringCap);
        if (cpuTemperatureC !== null)
            tempRing = Sys.pushRing(tempRing, cpuTemperatureC, ringCap);

        netRing = Sys.pushRing(netRing, networkRxRate + networkTxRate, ringCap);
        netRxRing = Sys.pushRing(netRxRing, networkRxRate, ringCap);
        netTxRing = Sys.pushRing(netTxRing, networkTxRate, ringCap);
        diskRing = Sys.pushRing(diskRing, diskReadRate + diskWriteRate, ringCap);
        diskReadRing = Sys.pushRing(diskReadRing, diskReadRate, ringCap);
        diskWriteRing = Sys.pushRing(diskWriteRing, diskWriteRate, ringCap);
        networkHistorySamples = Sys.pushRing(networkHistorySamples, Math.max(networkRxLevel(), networkTxLevel()), ringCap);
    }

    // --- slow loop: process spawns, only while popout open (5s) ---
    function refreshSlow() {
        if (!popoutOpen)
            return;

        if (!failedSystemProc.running)
            failedSystemProc.exec(["systemctl", "--failed", "--no-legend", "--plain"]);

        if (!failedUserProc.running)
            failedUserProc.exec(["systemctl", "--user", "--failed", "--no-legend", "--plain"]);

        if (!incidentProc.running)
            incidentProc.exec(Problems.journalCommand());

        if (!dfProc.running)
            dfProc.exec(["df", "-P"]);

        if (!psProc.running)
            psProc.exec(["sh", "-c", "ps -eo pid=,comm=,pcpu=,pmem= --sort=-pcpu | head -n 5"]);
    }

    // --- rare loop: identity facts (cached) + checkupdates (async, on open) ---
    function refreshRare() {
        if (!identityLoaded && !identityProc.running)
            identityProc.exec(["sh", "-c", "printf 'host=%s\\n' \"$(uname -n)\"; printf 'kernel=%s\\n' \"$(uname -r)\"; ( . /etc/os-release 2>/dev/null; printf 'distro=%s\\n' \"${PRETTY_NAME:-Linux}\" ); printf 'model=%s\\n' \"$(cat /sys/devices/virtual/dmi/id/product_version 2>/dev/null || cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null)\"; printf 'shell=%s\\n' \"$(basename \"${SHELL:-sh}\")\"; printf 'uptime=%s\\n' \"$(uptime -p 2>/dev/null | sed 's/^up //')\"; printf 'packages=%s\\n' \"$(pacman -Qq 2>/dev/null | wc -l)\""]);

        if (!updatesProc.running)
            updatesProc.exec(["checkupdates"]);
    }

    function recomputeFailed() {
        failedUnits = failedSystemUnits.concat(failedUserUnits);
    }

    // --- assemble detectIssues input (keys MUST match the sysinfo.js contract) ---
    function buildIssueState() {
        return {
            "failedUnits": failedUnits,
            "failedScopes": [
                {
                    scope: "system",
                    units: failedSystemUnits
                },
                {
                    scope: "user",
                    units: failedUserUnits
                }
            ],
            "cpuTemperatureC": cpuTemperatureC,
            "tempHighC": tempHighC,
            "degradationReason": degradationReason,
            "diskUsage": diskUsage,
            "memoryUsage": memoryUsage,
            "swapGrowing": swapGrowing,
            "healthPercentage": healthPercentage,
            "isLaptopBattery": isLaptopBattery,
            "loadOne": loadOne,
            "cores": cores,
            "updateCount": updateCount
        };
    }

    // returns the issue ARRAY (Repeater binds directly); not stringified.
    function issues() {
        return Sys.detectIssues(buildIssueState()).concat(Problems.groupIncidents(incidents));
    }

    // click-to-act: pure planner decides argv; QML only launches.
    function runIssueAction(id) {
        const result = Res.issueAction(issues(), id);
        if (!result.ok)
            return JSON.stringify(result);

        lastAction = result;
        Quickshell.execDetached(result.argv);
        return JSON.stringify(lastAction);
    }

    function runIssueAgent(id) {
        if (agentPending)
            return;
        const issue = issues().find(entry => entry.id === String(id));
        if (!Res.agentAllowed(issue))
            return;
        agentIssueId = String(id);
        agentError = "";
        agentPromptLoading = true;
        repairPrompt.reload();
    }

    function prepareIssueAgent(template: string): void {
        const plan = Res.agentAction(issues(), agentIssueId, {
            home: Quickshell.env("HOME"),
            stateHome: Quickshell.env("XDG_STATE_HOME"),
            observedAt: new Date().toISOString(),
            identity: identity
        }, template);
        agentPromptLoading = false;
        if (!plan.ok) {
            agentError = plan.reason === "prompt-invalid" ? "repair prompt unavailable or invalid" : plan.reason === "home-unavailable" ? "home directory unavailable" : "issue no longer eligible";
            return;
        }
        pendingAgent = plan;
        // Fixed script only; no issue text ever enters shell syntax.
        // File transport preserves all retained occurrences without Linux's
        // per-argument size limit. Requests are private, immutable repair evidence.
        agentCheck.stdinEnabled = true;
        agentCheck.exec(["sh", "-c", "command -v pi && command -v wezterm || { cat >/dev/null; exit 127; }; umask 077; mkdir -p -- \"$1\" && test -d \"$1\" && test -w \"$1\" && test -x \"$1\" || { cat >/dev/null; exit 73; }; request=$(mktemp -- \"$1/.request-XXXXXX.md\") || { cat >/dev/null; exit 74; }; cat >\"$request\" || { rm -- \"$request\"; exit 74; }; printf '%s\\n' \"${request##*/}\"", "nuguland-repairs", plan.argv[3]]);
    }

    // --- trimmed IPC contract (KEEP — see nuguland-statejson-is-ipc-contract) ---
    function stateJson() {
        const memUsed = Math.max(0, memTotal - memAvailable);
        const swapUsed = Math.max(0, swapTotal - swapFree);
        return JSON.stringify({
            "kind": "system",
            "title": "system",
            "popoutOpen": popoutOpen,
            "cpu": {
                "usage": cpuUsage,
                "percent": Math.round(cpuUsage * 100)
            },
            "memory": {
                "usage": memoryUsage,
                "percent": Math.round(memoryUsage * 100),
                "used": memUsed,
                "total": memTotal,
                "label": `${Sys.formatBytes(memUsed)} / ${Sys.formatBytes(memTotal)}`
            },
            "swap": {
                "usage": swapUsage,
                "percent": Math.round(swapUsage * 100),
                "used": swapUsed,
                "total": swapTotal,
                "label": swapTotal > 0 ? `${Sys.formatBytes(swapUsed)} / ${Sys.formatBytes(swapTotal)}` : "off"
            },
            "network": {
                "rxRate": networkRxRate,
                "txRate": networkTxRate,
                "label": `↓ ${Sys.formatRate(networkRxRate)}  ↑ ${Sys.formatRate(networkTxRate)}`
            },
            "disk": {
                "readRate": diskReadRate,
                "writeRate": diskWriteRate,
                "label": `R ${Sys.formatRate(diskReadRate)}  W ${Sys.formatRate(diskWriteRate)}`
            },
            "temperature": {
                "celsius": cpuTemperatureC,
                "highThresholdC": tempHighC,
                "label": cpuTemperatureC !== null ? `${Math.round(Number(cpuTemperatureC))}°C` : "n/a"
            },
            "fan": {
                "rpm": fanRpm,
                "label": fanRpm !== null ? `${Math.round(Number(fanRpm))} rpm` : "n/a"
            },
            "load": {
                "one": loadOne,
                "five": loadFive,
                "fifteen": loadFifteen,
                "cores": cores
            },
            "battery": {
                "percent": batteryPercent,
                "status": batteryStatus,
                "healthPercentage": healthPercentage,
                "isLaptopBattery": isLaptopBattery
            },
            "identity": identity,
            "updateCount": updateCount,
            "processes": processes,
            "issues": issues(),
            "lastAction": lastAction
        });
    }

    // backward-compatible alias for the existing shell.qml "systemWidgetState" IPC
    function systemWidgetState() {
        return stateJson();
    }

    // --- integration selfTest: exercises the QML glue (buildIssueState keys,
    //     issues()/stateJson()), not sysinfo.js (already node-tested). ---
    function selfTest() {
        const cases = [];
        function ok(name, cond) {
            cases.push({
                "name": name,
                "pass": !!cond
            });
        }
        function findIssue(list, id) {
            for (let i = 0; i < list.length; i++) {
                if (list[i].id === id)
                    return list[i];
            }
            return null;
        }
        const saved = {
            "failedUnits": failedUnits,
            "cpuTemperatureC": cpuTemperatureC,
            "tempHighC": tempHighC,
            "diskUsage": diskUsage,
            "memoryUsage": memoryUsage,
            "swapGrowing": swapGrowing,
            "loadOne": loadOne,
            "cores": cores,
            "updateCount": updateCount
        };
        try {
            const st = buildIssueState();
            const keys = ["failedUnits", "cpuTemperatureC", "tempHighC", "degradationReason", "diskUsage", "memoryUsage", "swapGrowing", "healthPercentage", "isLaptopBattery", "loadOne", "cores", "updateCount"];
            const missing = [];
            for (let k = 0; k < keys.length; k++) {
                if (!(keys[k] in st))
                    missing.push(keys[k]);
            }
            ok("state-keys-present:" + missing.join(","), missing.length === 0);

            failedUnits = ["app-Handy@autostart.service"];
            cpuTemperatureC = 95;
            tempHighC = 87;
            diskUsage = [
                {
                    "source": "/dev/sda3",
                    "target": "/",
                    "usedPercent": 96
                }
            ];
            memoryUsage = 0.95;
            swapGrowing = true;
            loadOne = 9;
            cores = 4;
            updateCount = 3;
            const list = root.issues();
            ok("issues-is-array", Array.isArray(list));
            ok("failed-services", !!findIssue(list, "failed-services"));
            const th = findIssue(list, "thermal");
            ok("thermal-warning", th && th.severity === "warning");
            const dk = findIssue(list, "low-disk:/dev/sda3");
            ok("low-disk-error", dk && dk.severity === "error");
            ok("mem-pressure", !!findIssue(list, "mem-pressure"));
            ok("high-load", !!findIssue(list, "high-load"));
            const up = findIssue(list, "updates");
            ok("updates-info", up && up.severity === "info");

            // below threshold -> these checks clear (battery/throttle ignored: real device)
            failedUnits = [];
            cpuTemperatureC = 40;
            diskUsage = [
                {
                    "source": "/dev/sda3",
                    "target": "/",
                    "usedPercent": 50
                }
            ];
            memoryUsage = 0.5;
            swapGrowing = false;
            loadOne = 1;
            updateCount = 0;
            const clear = root.issues();
            ok("failed-cleared", !findIssue(clear, "failed-services"));
            ok("thermal-cleared", !findIssue(clear, "thermal"));
            ok("disk-cleared", !findIssue(clear, "low-disk:/dev/sda3"));
            ok("mem-cleared", !findIssue(clear, "mem-pressure"));
            ok("load-cleared", !findIssue(clear, "high-load"));
            ok("updates-cleared", !findIssue(clear, "updates"));

            const sj = JSON.parse(root.stateJson());
            ok("statejson-kind", sj.kind === "system");
            ok("statejson-issues-array", Array.isArray(sj.issues));
        } catch (e) {
            ok("threw:" + e, false);
        } finally {
            failedUnits = saved.failedUnits;
            cpuTemperatureC = saved.cpuTemperatureC;
            tempHighC = saved.tempHighC;
            diskUsage = saved.diskUsage;
            memoryUsage = saved.memoryUsage;
            swapGrowing = saved.swapGrowing;
            loadOne = saved.loadOne;
            cores = saved.cores;
            updateCount = saved.updateCount;
        }
        const fail = cases.filter(c => {
            return !c.pass;
        }).length;
        return JSON.stringify({
            "pass": cases.length - fail,
            "fail": fail,
            "cases": cases
        });
    }

    onPopoutOpenChanged: {
        if (popoutOpen) {
            refreshRare();
            refreshSlow();
        }
    }

    Component.onCompleted: {
        // resolve hwmon temp (coretemp package) + fan (thinkpad) paths once.
        hwmonResolve.exec(["sh", "-c", "for d in /sys/class/hwmon/hwmon*; do n=$(cat \"$d/name\" 2>/dev/null); if [ \"$n\" = coretemp ]; then for t in \"$d\"/temp*_label; do [ -r \"$t\" ] || continue; if grep -qi package \"$t\"; then b=${t%_label}; [ -r \"${b}_input\" ] && printf 'temp=%s\\n' \"${b}_input\"; if [ -r \"${b}_max\" ]; then printf 'high=%s\\n' \"$(cat ${b}_max)\"; elif [ -r \"${b}_crit\" ]; then printf 'high=%s\\n' \"$(cat ${b}_crit)\"; fi; break; fi; done; elif [ \"$n\" = thinkpad ]; then [ -r \"$d/fan1_input\" ] && printf 'fan=%s\\n' \"$d/fan1_input\"; fi; done"]);
    }

    // --- FileViews: pure reads, last-good on failure (printErrors off) ---
    FileView {
        id: statFile

        path: "/proc/stat"
        blockLoading: true
        printErrors: false
    }

    FileView {
        id: meminfoFile

        path: "/proc/meminfo"
        blockLoading: true
        printErrors: false
    }

    FileView {
        id: netFile

        path: "/proc/net/dev"
        blockLoading: true
        printErrors: false
    }

    FileView {
        id: diskFile

        path: "/proc/diskstats"
        blockLoading: true
        printErrors: false
    }

    FileView {
        id: loadFile

        path: "/proc/loadavg"
        blockLoading: true
        printErrors: false
    }

    // paths resolved at runtime by hwmonResolve; empty until then.
    FileView {
        id: tempFile

        path: ""
        blockLoading: true
        printErrors: false
    }

    FileView {
        id: fanFile

        path: ""
        blockLoading: true
        printErrors: false
    }

    // --- one-shot hwmon path resolution ---
    Process {
        id: hwmonResolve

        running: false

        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: {
                const lines = String(this.text || "").split("\n");
                for (let i = 0; i < lines.length; i++) {
                    const line = lines[i].trim();
                    if (line.indexOf("temp=") === 0)
                        tempFile.path = line.slice(5);
                    else if (line.indexOf("fan=") === 0)
                        fanFile.path = line.slice(4);
                    else if (line.indexOf("high=") === 0) {
                        const v = Number(line.slice(5));
                        if (Number.isFinite(v) && v > 0)
                            root.tempHighC = v / 1000;
                    }
                }
            }
        }
    }

    FileView {
        id: repairPrompt
        path: Qt.resolvedUrl("prompts/system-repair.md")
        printErrors: false
        onLoaded: if (root.agentPromptLoading)
            root.prepareIssueAgent(repairPrompt.text())
        onLoadFailed: {
            if (root.agentPromptLoading) {
                root.agentPromptLoading = false;
                root.agentError = "repair prompt unavailable or invalid";
            }
        }
    }

    Process {
        id: agentCheck
        stdout: StdioCollector {
            id: agentPaths
            waitForEnd: true
        }
        function handleExit(exitCode: int): void {
            const paths = String(agentPaths.text).trim().split("\n");
            if (exitCode === 73) {
                root.agentError = "repair directory unavailable or not writable";
                return;
            }
            if (exitCode === 74) {
                root.agentError = "repair context could not be saved";
                return;
            }
            if (exitCode !== 0 || paths.length !== 3 || paths[0][0] !== "/" || paths[1][0] !== "/" || !/^\.request-[A-Za-z0-9]+\.md$/.test(paths[2])) {
                root.agentError = "pi or wezterm unavailable in session path";
                return;
            }
            const argv = root.pendingAgent.argv.slice();
            argv[0] = paths[1];
            argv[5] = paths[0];
            argv[argv.length - 1] = "@" + argv[3] + "/" + paths[2];
            root.lastAction = {
                id: root.pendingAgent.id,
                kind: "agent",
                status: "dispatched"
            };
            Quickshell.execDetached(argv);
            root.pendingAgent = ({});
            root.agentDispatched();
        }
    }

    // --- slow-loop processes ---
    Process {
        id: incidentProc
        stdout: StdioCollector {
            id: incidentOut
            waitForEnd: true
        }
        stderr: StdioCollector {
            id: incidentErr
            waitForEnd: true
        }
        function handleExit(exitCode: int): void {
            // No-match with --grep exits 1. Diagnostics mean incomplete visibility.
            const parsed = Problems.parseJournal(incidentOut.text);
            const empty = !String(incidentOut.text).trim();
            if ((exitCode === 0 || (exitCode === 1 && empty)) && !String(incidentErr.text).trim() && parsed.ok) {
                root.incidents = parsed.issues;
                root.incidentStatus = "ready";
            } else {
                root.incidentStatus = "history unavailable or incomplete";
            }
        }
    }

    Process {
        id: failedSystemProc

        running: false

        stdout: StdioCollector {
            id: failedSystemOut
            waitForEnd: true
        }
        function handleExit(exitCode: int): void {
            if (exitCode === 0) {
                root.failedSystemUnits = Sys.parseFailedUnits(failedSystemOut.text);
                root.recomputeFailed();
            }
        }
    }

    Process {
        id: failedUserProc

        running: false

        stdout: StdioCollector {
            id: failedUserOut
            waitForEnd: true
        }
        function handleExit(exitCode: int): void {
            if (exitCode === 0) {
                root.failedUserUnits = Sys.parseFailedUnits(failedUserOut.text);
                root.recomputeFailed();
            }
        }
    }

    Process {
        id: dfProc

        running: false

        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: {
                const parsed = Sys.parseDf(this.text);
                if (parsed.length > 0)
                    root.diskUsage = parsed;
            }
        }
    }

    Process {
        id: psProc

        running: false

        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: {
                const parsed = Sys.parsePsTop(this.text);
                if (parsed.length > 0)
                    root.processes = parsed;
            }
        }
    }

    // --- rare-loop processes ---
    Process {
        id: identityProc

        running: false

        stdout: StdioCollector {
            waitForEnd: true
            onStreamFinished: {
                const out = {
                    "wm": "niri"
                };
                const lines = String(this.text || "").split("\n");
                for (let i = 0; i < lines.length; i++) {
                    const eq = lines[i].indexOf("=");
                    if (eq <= 0)
                        continue;

                    out[lines[i].slice(0, eq)] = lines[i].slice(eq + 1).trim();
                }
                if (out.host) {
                    root.identity = out;
                    root.identityLoaded = true;
                }
            }
        }
    }

    Process {
        id: updatesProc

        running: false

        stdout: StdioCollector {
            id: updatesOut

            waitForEnd: true
        }

        // checkupdates: 0 = updates listed, 2 = none, other = error / no network.
        function handleExit(exitCode: int): void {
            if (exitCode === 2) {
                root.updateCount = 0;
                return;
            }
            if (exitCode !== 0) {
                root.updateCount = null;
                return;
            }
            const lines = String(updatesOut.text || "").split("\n").filter(l => {
                return l.trim().length > 0;
            });
            root.updateCount = lines.length;
        }
    }

    // Explicit Connections register real handlers without requiring the missing
    // QProcess::ExitStatus enum in Quickshell's installed qmltypes metadata.
    Connections {
        target: agentCheck
        function onStarted(): void {
            const argv = root.pendingAgent.argv;
            agentCheck.write(argv[argv.length - 1]);
            agentCheck.stdinEnabled = false;
        }
        function onExited(exitCode: int): void {
            agentCheck.handleExit(exitCode);
        }
    }
    Connections {
        target: incidentProc
        function onExited(exitCode: int): void {
            incidentProc.handleExit(exitCode);
        }
    }
    Connections {
        target: failedSystemProc
        function onExited(exitCode: int): void {
            failedSystemProc.handleExit(exitCode);
        }
    }
    Connections {
        target: failedUserProc
        function onExited(exitCode: int): void {
            failedUserProc.handleExit(exitCode);
        }
    }
    Connections {
        target: updatesProc
        function onExited(exitCode: int): void {
            updatesProc.handleExit(exitCode);
        }
    }

    // --- cadence timers ---
    Timer {
        id: fastTimer

        interval: 2000
        repeat: true
        running: true
        triggeredOnStart: true
        onTriggered: root.refreshFast()
    }

    Timer {
        id: slowTimer

        interval: 5000
        repeat: true
        running: root.popoutOpen
        triggeredOnStart: true
        onTriggered: root.refreshSlow()
    }

    Timer {
        id: rareTimer

        interval: 300000
        repeat: true
        running: root.popoutOpen
        triggeredOnStart: false
        onTriggered: root.refreshRare()
    }
}