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:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(coredumpctl list:*)",
|
||||||
|
"Bash(coredumpctl info:*)",
|
||||||
|
"Bash(journalctl:*)",
|
||||||
|
"Bash(rpm -q:*)",
|
||||||
|
"Bash(panama doctor:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,7 +170,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
179 of them, under `tests/`. Run the lot, or a subset by pattern:
|
180 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
@@ -212,6 +212,7 @@ panama update # bring this machine up to date; asks nothing
|
|||||||
panama sync # review, commit and push your changes to this repo
|
panama sync # review, commit and push your changes to this repo
|
||||||
panama edit # open it in Neovim
|
panama edit # open it in Neovim
|
||||||
panama doctor # what is actually running, not what was installed
|
panama doctor # what is actually running, not what was installed
|
||||||
|
panama diagnose # hand the health summary and recent errors to your agent
|
||||||
panama test # every contract, or a subset by pattern
|
panama test # every contract, or a subset by pattern
|
||||||
panama test --safe # the same, minus the ones that take over the desktop
|
panama test --safe # the same, minus the ones that take over the desktop
|
||||||
panama contracts <file> # which contracts mention a file, and can they be run
|
panama contracts <file> # which contracts mention a file, and can they be run
|
||||||
|
|||||||
+81
@@ -9,6 +9,7 @@
|
|||||||
# sync Review, commit & push local changes to this repo
|
# sync Review, commit & push local changes to this repo
|
||||||
# edit Open the Panama repo in Neovim
|
# edit Open the Panama repo in Neovim
|
||||||
# doctor Report what is actually running on this machine
|
# doctor Report what is actually running on this machine
|
||||||
|
# diagnose Hand this machine's health and recent errors to your agent
|
||||||
# test Run every contract under tests/ (--safe skips the hijacking ones)
|
# test Run every contract under tests/ (--safe skips the hijacking ones)
|
||||||
# contracts Name the contracts that mention a given file
|
# contracts Name the contracts that mention a given file
|
||||||
# upgrade Re-run the installer from anywhere, interview included
|
# upgrade Re-run the installer from anywhere, interview included
|
||||||
@@ -81,6 +82,9 @@ ${BOLD}Commands:${RESET}
|
|||||||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||||||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||||||
than what was installed. Takes --summary for one line per check.
|
than what was installed. Takes --summary for one line per check.
|
||||||
|
${GREEN}diagnose${RESET} Hand the health summary, the recent journal errors and
|
||||||
|
whatever you say is wrong to your coding agent, in a terminal.
|
||||||
|
Needs an agent chosen on Settings › System › Agents.
|
||||||
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||||
--safe skips the ones that take over the live desktop; what
|
--safe skips the ones that take over the live desktop; what
|
||||||
@@ -111,6 +115,8 @@ ${BOLD}Examples:${RESET}
|
|||||||
$PROGRAM sync
|
$PROGRAM sync
|
||||||
$PROGRAM edit
|
$PROGRAM edit
|
||||||
$PROGRAM doctor --summary
|
$PROGRAM doctor --summary
|
||||||
|
$PROGRAM diagnose
|
||||||
|
$PROGRAM diagnose the bar disappears after unplugging the monitor
|
||||||
$PROGRAM test dock
|
$PROGRAM test dock
|
||||||
$PROGRAM test --safe
|
$PROGRAM test --safe
|
||||||
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
|
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
|
||||||
@@ -348,6 +354,80 @@ cmd_doctor() {
|
|||||||
exec "$doctor" "$@"
|
exec "$doctor" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: diagnose
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The by-hand rung of the escalation ladder. Every other rung starts from an
|
||||||
|
# event -- a crash, a failed reload, a red check -- and this one starts from a
|
||||||
|
# person who can tell that something is wrong but not what.
|
||||||
|
#
|
||||||
|
# It gathers the two things anybody would be asked for first anyway (what the
|
||||||
|
# health check says, what the journal has been complaining about) and whatever
|
||||||
|
# words follow the command, then hands the lot to the configured agent. The free
|
||||||
|
# text is the valuable part: "the bar disappears after unplugging the monitor"
|
||||||
|
# is a symptom no collector reports, and it is the difference between an agent
|
||||||
|
# reading a health summary and an agent looking for something.
|
||||||
|
cmd_diagnose() {
|
||||||
|
local launcher="$PANAMA_DIR/bin/panama-agent"
|
||||||
|
if [[ ! -x "$launcher" ]]; then
|
||||||
|
err "The agent launcher is missing from $launcher"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local complaint="$*"
|
||||||
|
|
||||||
|
local health="(the health check did not run)"
|
||||||
|
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||||||
|
if [[ -x "$doctor" ]]; then
|
||||||
|
health="$("$doctor" --summary 2>&1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bounded twice, and not out of tidiness. journalctl counts entries, not
|
||||||
|
# lines, and thirty entries on this machine came to 2,430 lines and a quarter
|
||||||
|
# of a megabyte -- one multi-line traceback each. The prompt leaves as a
|
||||||
|
# single argv element, which the kernel caps at 128KB, so an unbounded excerpt
|
||||||
|
# turns this command into "Argument list too long" rather than a diagnosis.
|
||||||
|
local errors="(nothing at error level in this boot's user journal)"
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
local recent
|
||||||
|
recent="$(journalctl --user -b -p err -n 30 --no-pager --output=short 2>/dev/null \
|
||||||
|
| cut -c 1-300 | tail -80)" || true
|
||||||
|
[[ -n "${recent// }" ]] && errors="$recent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local complaint_section="Nothing in particular was reported; this was run to look around."
|
||||||
|
[[ -n "${complaint// }" ]] && complaint_section="$complaint"
|
||||||
|
|
||||||
|
local prompt
|
||||||
|
prompt="$(cat <<PROMPT
|
||||||
|
Something is wrong with this Panama machine and I would like to know what.
|
||||||
|
|
||||||
|
What I noticed:
|
||||||
|
|
||||||
|
$complaint_section
|
||||||
|
|
||||||
|
What panama doctor --summary says:
|
||||||
|
|
||||||
|
$health
|
||||||
|
|
||||||
|
The last error-level lines in this boot's user journal:
|
||||||
|
|
||||||
|
$errors
|
||||||
|
|
||||||
|
Panama is checked out at $PANAMA_DIR and every dotfile in ~/.config is a symlink
|
||||||
|
into it, so anything you find is a tracked file here rather than a copy. Start
|
||||||
|
by reading: work out what is actually broken and say so before changing
|
||||||
|
anything. If a check is red, 'panama doctor' with no arguments has the long form
|
||||||
|
of it. Root work goes through panama-sudo, which shows me your reason.
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
# exec: from here on the agent's terminal is the process, and this shell has
|
||||||
|
# nothing left to do that the agent is not doing better.
|
||||||
|
exec "$launcher" --prompt "$prompt"
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# The desktop-hijacking ledger
|
# The desktop-hijacking ledger
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -805,6 +885,7 @@ main() {
|
|||||||
sync) shift; cmd_sync "$@" ;;
|
sync) shift; cmd_sync "$@" ;;
|
||||||
edit) shift; cmd_edit "$@" ;;
|
edit) shift; cmd_edit "$@" ;;
|
||||||
doctor) shift; cmd_doctor "$@" ;;
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
|
diagnose) shift; cmd_diagnose "$@" ;;
|
||||||
test) shift; cmd_test "$@" ;;
|
test) shift; cmd_test "$@" ;;
|
||||||
contracts) shift; cmd_contracts "$@" ;;
|
contracts) shift; cmd_contracts "$@" ;;
|
||||||
upgrade) shift; cmd_upgrade "$@" ;;
|
upgrade) shift; cmd_upgrade "$@" ;;
|
||||||
|
|||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Hand a prompt to whichever coding agent this machine has chosen.
|
||||||
|
#
|
||||||
|
# Every rung of the escalation ladder ends here: a crash toast, a failed shell
|
||||||
|
# reload, a red health check, `panama diagnose`. They gather facts; this decides
|
||||||
|
# which binary runs them and puts it in a terminal you can watch and interrupt.
|
||||||
|
#
|
||||||
|
# Two settings decide everything, and both are read at press time rather than at
|
||||||
|
# start time, so choosing an agent in Settings takes effect on the next crash
|
||||||
|
# without restarting anything:
|
||||||
|
#
|
||||||
|
# preferredAgent none | claude | codex ("none" is the default: silence)
|
||||||
|
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
|
||||||
|
# mode; false -> its normal prompting mode, untouched.
|
||||||
|
#
|
||||||
|
# "none" exits 0 without a word. It is not an error to have no agent; it is the
|
||||||
|
# shipped state, and a rung that shouted about it would be a rung that gets
|
||||||
|
# turned off.
|
||||||
|
#
|
||||||
|
# panama-agent open the agent on the repo
|
||||||
|
# panama-agent --prompt "text" open it with something to work on
|
||||||
|
#
|
||||||
|
# Environment seams, for the contract and for a second checkout:
|
||||||
|
#
|
||||||
|
# PANAMA_PATH the repository; also the agent's working directory
|
||||||
|
# PANAMA_AGENT_SETTINGS the settings file to read (default: the real one)
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapted from Omarchy's bin/omarchy-agent (https://github.com/basecamp/omarchy)
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||||
|
|
||||||
|
# A fixed window class rather than the per-binary default, so one Hyprland rule
|
||||||
|
# can catch every agent window regardless of which agent is chosen.
|
||||||
|
readonly WINDOW_CLASS="panama-agent"
|
||||||
|
|
||||||
|
# THE INHERITED PATH IS NOT THE USER'S PATH. Every rung except `panama diagnose`
|
||||||
|
# reaches this script from the Quickshell shell, which systemd starts with
|
||||||
|
# neither PANAMA_PATH nor ~/.local/bin -- and ~/.local/bin is where both agents
|
||||||
|
# install themselves. Trusting PATH here meant a perfectly well installed agent
|
||||||
|
# reporting itself as missing, into the stderr of a detached process nobody will
|
||||||
|
# ever read: the whole ladder failing silently, which is the exact failure it
|
||||||
|
# exists to prevent.
|
||||||
|
#
|
||||||
|
# So the binary is resolved rather than named. PATH first, because a user who
|
||||||
|
# put an agent somewhere else meant it; then the XDG user bin directory, which
|
||||||
|
# is where the installers actually put them.
|
||||||
|
resolve_agent() {
|
||||||
|
local name="$1" found
|
||||||
|
found="$(command -v "$name" 2>/dev/null)" && { printf '%s' "$found"; return 0; }
|
||||||
|
[[ -x "$HOME/.local/bin/$name" ]] && { printf '%s' "$HOME/.local/bin/$name"; return 0; }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The same repair, for the agent's own sake rather than this script's: an agent
|
||||||
|
# launched from a notification click would otherwise run every shell command it
|
||||||
|
# is asked to with a PATH unlike the one the user gets in a terminal. Applied
|
||||||
|
# just before the spawn rather than here, so resolve_agent above is answering
|
||||||
|
# the question the caller actually asked -- "can this be found from where I was
|
||||||
|
# started" -- instead of one this script has already fixed for itself.
|
||||||
|
repair_path() {
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$HOME/.local/bin:"*) ;;
|
||||||
|
*) PATH="$PATH:$HOME/.local/bin" ;;
|
||||||
|
esac
|
||||||
|
export PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: panama-agent [--prompt "text"]
|
||||||
|
|
||||||
|
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
|
||||||
|
With no agent chosen, exits silently: choose one on Settings > System > Agents.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt=""
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--prompt)
|
||||||
|
prompt="${2:?--prompt needs a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'panama-agent: unexpected argument: %s\n' "$1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Same shape as panama-idle's reader: a missing file, a missing key and an
|
||||||
|
# explicit null all mean "the default", because all three describe a machine
|
||||||
|
# that has never been asked the question.
|
||||||
|
read_setting() {
|
||||||
|
local key="$1" fallback="$2"
|
||||||
|
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||||
|
jq -r --arg k "$key" --arg d "$fallback" \
|
||||||
|
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||||
|
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
agent="$(read_setting preferredAgent none)"
|
||||||
|
|
||||||
|
# The shipped state. Nothing to launch, nothing to say.
|
||||||
|
[[ -n "$agent" && "$agent" != "none" ]] || exit 0
|
||||||
|
|
||||||
|
auto_approve="$(read_setting agentAutoApprove true)"
|
||||||
|
|
||||||
|
case "$agent" in
|
||||||
|
claude | codex) ;;
|
||||||
|
*)
|
||||||
|
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
|
||||||
|
printf 'Choose one on Settings > System > Agents.\n' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Resolved to a path before argv is built, so kitty is never asked to repeat a
|
||||||
|
# PATH lookup this script has already done more carefully than kitty could.
|
||||||
|
if ! agent_bin="$(resolve_agent "$agent")"; then
|
||||||
|
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
|
||||||
|
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
|
||||||
|
printf 'Install it, or choose another agent on Settings > System > Agents.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The per-agent launch table. VERIFIED against the installed binaries' --help on
|
||||||
|
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
|
||||||
|
# from release to release, so re-run --help before changing them.
|
||||||
|
#
|
||||||
|
# claude --permission-mode auto "auto" is one of acceptEdits/auto/
|
||||||
|
# bypassPermissions/manual/dontAsk/plan
|
||||||
|
# codex --approve-for-me routes approvals through automatic review
|
||||||
|
# inside the workspace-write sandbox
|
||||||
|
#
|
||||||
|
# With agentAutoApprove off, no mode flag is passed at all: the agent's own
|
||||||
|
# configured default is a choice the user already made, and overriding it with
|
||||||
|
# an explicit "prompt me" would be this script having an opinion it was told not
|
||||||
|
# to have.
|
||||||
|
declare -a argv=("$agent_bin")
|
||||||
|
case "$agent" in
|
||||||
|
claude) [[ "$auto_approve" == "true" ]] && argv+=(--permission-mode auto) ;;
|
||||||
|
codex) [[ "$auto_approve" == "true" ]] && argv+=(--approve-for-me) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# One argv element, after the option terminator. Both CLIs take the prompt as a
|
||||||
|
# trailing positional, and `--` is what stops a prompt beginning with a dash --
|
||||||
|
# or one that happens to read like a subcommand -- from being parsed as flags.
|
||||||
|
[[ -n "$prompt" ]] && argv+=(-- "$prompt")
|
||||||
|
|
||||||
|
# The checkout, not $HOME: the skills the prompts point at, the repository the
|
||||||
|
# agent is being asked about, and .claude/settings.json's pre-approved read-only
|
||||||
|
# diagnostics all live here. An agent started anywhere else finds none of them.
|
||||||
|
cd "$PANAMA_PATH"
|
||||||
|
|
||||||
|
repair_path
|
||||||
|
|
||||||
|
# setsid so the agent outlives whatever spawned it -- a notification handler, a
|
||||||
|
# crash watcher, a terminal that is about to close.
|
||||||
|
exec setsid kitty \
|
||||||
|
--directory "$PANAMA_PATH" \
|
||||||
|
--class "$WINDOW_CLASS" \
|
||||||
|
-e "${argv[@]}"
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# "Something crashed" -> an agent already reading the core dump.
|
||||||
|
#
|
||||||
|
# Reached by clicking the crash notification panama-crash-watch sends, or run by
|
||||||
|
# hand against any PID in `coredumpctl list`. It gathers the four facts
|
||||||
|
# systemd-coredump recorded and points at the skill that says what to do with
|
||||||
|
# them; the method lives in the skill so it is edited in one place and works
|
||||||
|
# whichever agent is configured.
|
||||||
|
#
|
||||||
|
# panama-agent-crash <pid> [comm] [exe] [signal]
|
||||||
|
#
|
||||||
|
# The skill is named AND given as an absolute path. A harness with a skill
|
||||||
|
# mechanism follows the name; one without still has a file to read. That is the
|
||||||
|
# whole reason this ladder works for more than one agent.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapted from Omarchy's bin/omarchy-agent-crash
|
||||||
|
# (https://github.com/basecamp/omarchy)
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
|
||||||
|
pid="${1:-}"
|
||||||
|
if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
printf 'Not a PID: %s\n' "${pid:-<missing>}" >&2
|
||||||
|
printf 'Usage: panama-agent-crash <pid> [comm] [exe] [signal] (see: coredumpctl list)\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
comm="${2:-unknown}"
|
||||||
|
exe="${3:-unknown}"
|
||||||
|
signal="${4:-unknown}"
|
||||||
|
|
||||||
|
skill="$PANAMA_PATH/skills/diagnose-crash/SKILL.md"
|
||||||
|
|
||||||
|
# Looked up live so a PID typed by hand still gets a timestamp. A core that has
|
||||||
|
# already been rotated away costs only the timestamp, so this is allowed to
|
||||||
|
# fail: the other four facts are enough to start on.
|
||||||
|
when="$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4)" || true
|
||||||
|
when="${when:-unknown}"
|
||||||
|
[[ -n "${when// }" ]] || when="unknown"
|
||||||
|
|
||||||
|
prompt="$(
|
||||||
|
cat <<PROMPT
|
||||||
|
A process crashed on this Panama machine and I want to know why.
|
||||||
|
|
||||||
|
What systemd-coredump recorded:
|
||||||
|
process: $comm
|
||||||
|
PID: $pid
|
||||||
|
binary: $exe
|
||||||
|
signal: $signal
|
||||||
|
time: $when
|
||||||
|
|
||||||
|
Use the diagnose-crash skill. It covers how to investigate, what to rule out
|
||||||
|
first, and what to report. If your harness has no skill mechanism, read the
|
||||||
|
skill file directly and follow it instead:
|
||||||
|
|
||||||
|
$skill
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# "The shell would not reload" -> an agent already holding the error.
|
||||||
|
#
|
||||||
|
# Quickshell keeps the old shell running when a reload fails, which is what
|
||||||
|
# makes this rung possible at all: the desktop that just refused the new code is
|
||||||
|
# still there to notify you about it, and still there to click. shell.qml's
|
||||||
|
# onReloadFailed sends that notification; this builds the prompt behind it.
|
||||||
|
#
|
||||||
|
# panama-agent-reload "<what Quickshell said>"
|
||||||
|
#
|
||||||
|
# The failure string on its own is usually one line naming one file. The journal
|
||||||
|
# around it is where the rest is -- the QML warnings that preceded the fatal
|
||||||
|
# one, the property that was already undefined two saves ago -- so both go in.
|
||||||
|
#
|
||||||
|
# Environment seams, for the contract:
|
||||||
|
#
|
||||||
|
# PANAMA_PATH the repository
|
||||||
|
# PANAMA_RELOAD_UNIT the unit to read (default panama-quickshell.service)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
UNIT="${PANAMA_RELOAD_UNIT:-panama-quickshell.service}"
|
||||||
|
|
||||||
|
summary="${1:-}"
|
||||||
|
if [[ -z "${summary// }" ]]; then
|
||||||
|
printf 'Usage: panama-agent-reload "<the reload failure>"\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read a generous window and filter it down, rather than asking journalctl for
|
||||||
|
# forty lines and hoping they were the relevant ones. A failed reload usually
|
||||||
|
# arrives after a burst of unrelated shell chatter.
|
||||||
|
#
|
||||||
|
# Each line is truncated because the prompt leaves as one argv element and the
|
||||||
|
# kernel caps that at 128KB; a single Quickshell backtrace can be most of it.
|
||||||
|
context=""
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
context="$(journalctl --user -u "$UNIT" -n 400 --no-pager --output=cat 2>/dev/null \
|
||||||
|
| grep -iE 'quickshell|\.qml|qml:|panama' \
|
||||||
|
| tail -40 \
|
||||||
|
| cut -c 1-300)" || true
|
||||||
|
fi
|
||||||
|
[[ -n "${context// }" ]] || context="(nothing in the journal for $UNIT)"
|
||||||
|
|
||||||
|
prompt="$(
|
||||||
|
cat <<PROMPT
|
||||||
|
The Panama shell refused to reload on this machine. The old shell is still
|
||||||
|
running, so the desktop is up, but the change that was just saved is not live.
|
||||||
|
|
||||||
|
What Quickshell reported:
|
||||||
|
|
||||||
|
$summary
|
||||||
|
|
||||||
|
The last relevant lines from $UNIT:
|
||||||
|
|
||||||
|
$context
|
||||||
|
|
||||||
|
The shell lives in config/dot/quickshell in this repository, symlinked into
|
||||||
|
~/.config/quickshell -- so the file that failed to parse is a tracked file here,
|
||||||
|
not a copy. Find what broke the reload and say what it is. Read before you
|
||||||
|
write: a bad guess saved into this tree is live in the desktop immediately.
|
||||||
|
PROMPT
|
||||||
|
)"
|
||||||
|
|
||||||
|
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
|
||||||
+73
-3
@@ -16,10 +16,18 @@
|
|||||||
# few minutes for something the user can do nothing about. The first one is
|
# few minutes for something the user can do nothing about. The first one is
|
||||||
# news; the fortieth is why people turn notifications off. The health page
|
# news; the fortieth is why people turn notifications off. The health page
|
||||||
# carries the running count for anyone who wants it.
|
# carries the running count for anyone who wants it.
|
||||||
|
#
|
||||||
|
# When an agent has been chosen, the notification stops being a dead end. It
|
||||||
|
# carries the diagnosis command as data in a `panama-exec` hint, which the shell
|
||||||
|
# runs on click. Command-as-data rather than a libnotify action, because an
|
||||||
|
# action would tie the click to this process still being alive to hear it, and
|
||||||
|
# this process is a `journalctl -f` that outlives nothing in particular. The
|
||||||
|
# hint survives a shell restart and never blocks the watcher.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
|
||||||
|
|
||||||
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
|
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
|
||||||
# working when the wording changes and never matches a program that merely
|
# working when the wording changes and never matches a program that merely
|
||||||
@@ -37,6 +45,27 @@ for _ in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Read per crash rather than once at startup, so choosing an agent in Settings
|
||||||
|
# takes effect on the next crash instead of on the next login. This service runs
|
||||||
|
# for the life of the session; nothing restarts it when a preference changes.
|
||||||
|
read_setting() {
|
||||||
|
local key="$1" fallback="$2"
|
||||||
|
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
|
||||||
|
jq -r --arg k "$key" --arg d "$fallback" \
|
||||||
|
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
|
||||||
|
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# What to call the agent in a sentence aimed at a person.
|
||||||
|
agent_label() {
|
||||||
|
case "$1" in
|
||||||
|
claude) printf 'Claude Code' ;;
|
||||||
|
codex) printf 'Codex' ;;
|
||||||
|
*) printf '%s' "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
declare -A reported=()
|
declare -A reported=()
|
||||||
|
|
||||||
# -f from now, not from the boot: a session that starts after a crash should
|
# -f from now, not from the boot: a session that starts after a crash should
|
||||||
@@ -46,9 +75,15 @@ journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev
|
|||||||
| while IFS= read -r line; do
|
| while IFS= read -r line; do
|
||||||
[[ -n "$line" ]] || continue
|
[[ -n "$line" ]] || continue
|
||||||
|
|
||||||
uid="$(jq -r '.COREDUMP_UID // empty' <<<"$line" 2>/dev/null)"
|
# One jq per entry rather than one per field: the fields are read
|
||||||
exe="$(jq -r '.COREDUMP_EXE // empty' <<<"$line" 2>/dev/null)"
|
# together, and the click payload needs all of them.
|
||||||
comm="$(jq -r '.COREDUMP_COMM // empty' <<<"$line" 2>/dev/null)"
|
IFS=$'\t' read -r uid exe comm pid signal < <(
|
||||||
|
jq -r '[(.COREDUMP_UID // ""),
|
||||||
|
(.COREDUMP_EXE // ""),
|
||||||
|
(.COREDUMP_COMM // ""),
|
||||||
|
(.COREDUMP_PID // ""),
|
||||||
|
(.COREDUMP_SIGNAL_NAME // "")] | @tsv' <<<"$line" 2>/dev/null
|
||||||
|
)
|
||||||
|
|
||||||
# Another user's crash is not this session's business, and reporting it
|
# Another user's crash is not this session's business, and reporting it
|
||||||
# would leak what they are running.
|
# would leak what they are running.
|
||||||
@@ -63,11 +98,46 @@ journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev
|
|||||||
else
|
else
|
||||||
program="$comm"
|
program="$comm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Never announce our own machinery. A crash watcher that notifies about
|
||||||
|
# the crash watcher, or about the agent it just launched to investigate
|
||||||
|
# the last crash, is a loop with a toast in it.
|
||||||
|
[[ "$program" == panama-crash-* || "$program" == panama-agent* ]] && continue
|
||||||
|
|
||||||
[[ -z "${reported[$program]:-}" ]] || continue
|
[[ -z "${reported[$program]:-}" ]] || continue
|
||||||
reported[$program]=1
|
reported[$program]=1
|
||||||
|
|
||||||
|
# The toast can only offer a diagnosis if there is something to diagnose
|
||||||
|
# with. No agent, or the offer switched off, and it stays exactly the
|
||||||
|
# actionless notification it has always been.
|
||||||
|
agent="$(read_setting preferredAgent none)"
|
||||||
|
offer="$(read_setting crashDiagnoseOffer true)"
|
||||||
|
|
||||||
|
if [[ -n "$agent" && "$agent" != "none" && "$offer" != "false" && "$pid" =~ ^[0-9]+$ ]]; then
|
||||||
|
# By absolute path, not by name. The shell runs this hint, and the
|
||||||
|
# shell is started by systemd -- whose environment does not carry
|
||||||
|
# the repository's bin directory on PATH, so a bare name would
|
||||||
|
# click into "command not found".
|
||||||
|
exec_command="$(printf '%q %q %q %q %q' \
|
||||||
|
"$PANAMA_PATH/bin/panama-agent-crash" \
|
||||||
|
"$pid" "${comm:-$program}" "${exe:-unknown}" "${signal:-unknown}")"
|
||||||
|
|
||||||
|
# The hint is data, not privilege. Any process on this session bus
|
||||||
|
# could send one, and running it grants nothing a local process
|
||||||
|
# could not already do for itself.
|
||||||
|
# Same urgency as the plain report, deliberately. Making the
|
||||||
|
# clickable one critical would let a crash break through Do Not
|
||||||
|
# Disturb, which is a louder desktop than anybody asked for in
|
||||||
|
# exchange for an offer that keeps until it is read anyway.
|
||||||
|
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||||
|
--hint="string:panama-exec:$exec_command" \
|
||||||
|
"$program stopped unexpectedly" \
|
||||||
|
"Click to diagnose with $(agent_label "$agent")." \
|
||||||
|
2>/dev/null || true
|
||||||
|
else
|
||||||
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
notify-send --icon=dialog-error-symbolic --app-name=Panama \
|
||||||
"$program stopped unexpectedly" \
|
"$program stopped unexpectedly" \
|
||||||
"It crashed and was not able to recover. System Health has the details." \
|
"It crashed and was not able to recover. System Health has the details." \
|
||||||
2>/dev/null || true
|
2>/dev/null || true
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ cmd_run() {
|
|||||||
|
|
||||||
if (( failed )); then
|
if (( failed )); then
|
||||||
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
||||||
|
warn "If it keeps failing, hand it to an agent: panama diagnose"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
ok "This machine now matches the checkout."
|
ok "This machine now matches the checkout."
|
||||||
|
|||||||
@@ -168,8 +168,60 @@ Singleton {
|
|||||||
// general-purpose desktop should show without being asked.
|
// general-purpose desktop should show without being asked.
|
||||||
{
|
{
|
||||||
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
|
key: "showAgentUsage", type: "bool", def: false, group: "vitals",
|
||||||
label: "Claude usage",
|
label: "Agent usage",
|
||||||
detail: "Show how much of the Claude subscription has been used, beside the other vitals"
|
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 ─────────────────────────────────────────────────────────
|
// ── Battery ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ Singleton {
|
|||||||
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
|
||||||
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
|
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 ─────────────────────────────────────────────────────────────
|
// ── Battery ─────────────────────────────────────────────────────────────
|
||||||
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
|
||||||
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
|
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
|
// One number: whichever window across every collected agent is closest to its
|
||||||
// about to interrupt you.
|
// 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:
|
// 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
|
// it is a coding-tool readout, not something a general-purpose desktop shows
|
||||||
// without being asked.
|
// without being asked.
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
// came out sitting off-centre against the rest of the bar.
|
// came out sitting off-centre against the rest of the bar.
|
||||||
//
|
//
|
||||||
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
// Clickable, because a readout you cannot ask anything of is furniture. Left
|
||||||
// click opens the settings that govern it; hovering says which window the
|
// click opens the panel behind the number; right click opens the settings that
|
||||||
// number belongs to and when it resets.
|
// govern it.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -27,7 +27,7 @@ Pill {
|
|||||||
|
|
||||||
visible: Settings.showAgentUsage && AgentUsage.available
|
visible: Settings.showAgentUsage && AgentUsage.available
|
||||||
|
|
||||||
onActivated: ShellState.openSettings("bar")
|
onActivated: panel.visible = !panel.visible
|
||||||
onSecondaryActivated: ShellState.openSettings("bar")
|
onSecondaryActivated: ShellState.openSettings("bar")
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
@@ -58,4 +58,11 @@ Pill {
|
|||||||
width: 30
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything except the buttons: clicking the body runs the notification's
|
// A command the notification carried as data, in the `panama-exec` hint.
|
||||||
// default action, which is what GNOME does.
|
// 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 {
|
MouseArea {
|
||||||
id: hover
|
id: hover
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: root.bodyActivates ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: {
|
onClicked: root.activateBody()
|
||||||
if (root.defaultAction)
|
|
||||||
root.defaultAction.invoke();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconImage {
|
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 QtQuick
|
||||||
|
import Quickshell
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
@@ -66,6 +67,84 @@ SettingsPage {
|
|||||||
return detail + " · Repair runs: " + command;
|
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 {
|
function checksForGroup(group: string): var {
|
||||||
return Health.checks.filter(check => check.group === group
|
return Health.checks.filter(check => check.group === group
|
||||||
&& (check.status === "ok" || check.status === "unconfigured"));
|
&& (check.status === "ok" || check.status === "unconfigured"));
|
||||||
@@ -160,7 +239,12 @@ SettingsPage {
|
|||||||
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
|
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 emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
|
||||||
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.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 {
|
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 => {
|
renderedRows: rows.map(row => {
|
||||||
const objectName = String(row.objectName);
|
const objectName = String(row.objectName);
|
||||||
const parts = objectName.split(":");
|
const parts = objectName.split(":");
|
||||||
@@ -295,17 +379,53 @@ SettingsPage {
|
|||||||
id: issueRepeater
|
id: issueRepeater
|
||||||
model: root.issueChecks
|
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 var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool offersAgent:
|
||||||
|
root.canAskAgent(issueEntry.modelData, issueRow.repairFailed)
|
||||||
|
|
||||||
width: issueRows.width
|
width: issueRows.width
|
||||||
check: modelData
|
implicitHeight: issueRow.implicitHeight
|
||||||
detailText: root.repairDetail(modelData)
|
|
||||||
|
HealthCheckRow {
|
||||||
|
id: issueRow
|
||||||
|
|
||||||
|
width: issueEntry.width
|
||||||
|
- (issueEntry.offersAgent ? askAgentButton.width + 12 : 0)
|
||||||
|
check: issueEntry.modelData
|
||||||
|
detailText: root.repairDetail(issueEntry.modelData)
|
||||||
issue: true
|
issue: true
|
||||||
divider: index < issueRepeater.count - 1
|
divider: issueEntry.index < issueRepeater.count - 1
|
||||||
onActionRequested: check => root.handleAction(check)
|
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. |
|
| `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. |
|
| `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. |
|
| `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,
|
Lock-screen visuals belong only to **Appearance**: background source, blur,
|
||||||
clock, date, user name, and password-field presentation. **Power** owns when
|
clock, date, user name, and password-field presentation. **Power** owns when
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ Rectangle {
|
|||||||
case "containers": return containersPage;
|
case "containers": return containersPage;
|
||||||
case "ssh-keys": return sshKeysPage;
|
case "ssh-keys": return sshKeysPage;
|
||||||
case "services": return healthPage;
|
case "services": return healthPage;
|
||||||
|
case "agents": return agentsPage;
|
||||||
case "manual": return manualPage;
|
case "manual": return manualPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
default: return homePage;
|
default: return homePage;
|
||||||
@@ -275,6 +276,7 @@ Rectangle {
|
|||||||
Component { id: privacyPage; PrivacyPage {} }
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||||
Component { id: healthPage; HealthPage {} }
|
Component { id: healthPage; HealthPage {} }
|
||||||
|
Component { id: agentsPage; AgentsPage {} }
|
||||||
Component { id: manualPage; ManualPage {} }
|
Component { id: manualPage; ManualPage {} }
|
||||||
Component { id: aboutPage; AboutPage {} }
|
Component { id: aboutPage; AboutPage {} }
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ FocusAllowChips 1.0 FocusAllowChips.qml
|
|||||||
PasswordRow 1.0 PasswordRow.qml
|
PasswordRow 1.0 PasswordRow.qml
|
||||||
PrintersPage 1.0 PrintersPage.qml
|
PrintersPage 1.0 PrintersPage.qml
|
||||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||||
|
AgentsPage 1.0 AgentsPage.qml
|
||||||
HealthPage 1.0 HealthPage.qml
|
HealthPage 1.0 HealthPage.qml
|
||||||
HealthSummary 1.0 HealthSummary.qml
|
HealthSummary 1.0 HealthSummary.qml
|
||||||
HealthCheckRow 1.0 HealthCheckRow.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
|
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.
|
// A collector per agent writes one display-ready record into
|
||||||
// That split is the point: adding a second agent later is a collector, not a
|
// $XDG_STATE_HOME/panama/agents/usage/<agent>.json, and this only ever reads
|
||||||
// change here or in the widget, and nothing in QML ever sees a credential.
|
// 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:
|
// Discovery is by listing the directory rather than by naming the agents, so a
|
||||||
// the collector has never run, it ran and the session was stale, or it has
|
// record that appears is an agent that appears. Each record gets its own
|
||||||
// real numbers. The widget hides for the first two, which is right -- a bar
|
// 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.
|
// indicator that says "unknown" is worse than an empty space.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -21,72 +29,153 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// "ok" | "stale" | "unavailable" | "" (never collected)
|
readonly property string usageDir:
|
||||||
property string status: ""
|
(Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
|
||||||
property string detail: ""
|
+ "/panama/agents/usage"
|
||||||
|
|
||||||
// Percentages, 0-100, or -1 when the endpoint did not report one.
|
readonly property string updaterPath: Quickshell.shellDir + "/scripts/panama-agent-usage-update"
|
||||||
property int fiveHourUsed: -1
|
|
||||||
property int weekUsed: -1
|
|
||||||
property string weekResetsAt: ""
|
|
||||||
property string tier: ""
|
|
||||||
|
|
||||||
readonly property bool available: root.status === "ok"
|
// Agent ids with a record file on disk, in the order the directory listed
|
||||||
&& (root.fiveHourUsed >= 0 || root.weekUsed >= 0)
|
// 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
|
// 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.
|
// window across every agent is closest to its limit is the one about to
|
||||||
readonly property int headline: Math.max(root.fiveHourUsed, root.weekUsed)
|
// 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 bool available: root.headline >= 0
|
||||||
readonly property string statePath:
|
|
||||||
(Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
|
// Any agent asking to be retried sooner than the interval. A first probe
|
||||||
+ "/panama/agent-usage.json"
|
// 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 {
|
Timer {
|
||||||
interval: 5 * 60 * 1000
|
id: tick
|
||||||
|
interval: root.refreshMinutes * 60 * 1000
|
||||||
running: Settings.showAgentUsage
|
running: Settings.showAgentUsage
|
||||||
repeat: true
|
repeat: true
|
||||||
triggeredOnStart: 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 {
|
Process {
|
||||||
id: collect
|
id: collect
|
||||||
command: [root.helperPath]
|
command: [root.updaterPath]
|
||||||
onExited: record.reload()
|
onExited: scan.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
FileView {
|
// Which records exist. `sh -c` with the directory passed as an argument
|
||||||
id: record
|
// rather than interpolated: a path is data, and a state directory is not
|
||||||
path: root.statePath
|
// 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));
|
||||||
|
}
|
||||||
|
for (const id of root.agentIds)
|
||||||
|
if (found.indexOf(id) < 0)
|
||||||
|
root.forget(id);
|
||||||
|
root.agentIds = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Instantiator {
|
||||||
|
model: root.agentIds
|
||||||
|
|
||||||
|
delegate: FileView {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
path: root.usageDir + "/" + modelData + ".json"
|
||||||
printErrors: false
|
printErrors: false
|
||||||
watchChanges: true
|
watchChanges: true
|
||||||
onFileChanged: this.reload()
|
onFileChanged: this.reload()
|
||||||
onLoaded: {
|
onLoaded: root.absorb(modelData, this.text())
|
||||||
try {
|
onLoadFailed: root.forget(modelData)
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
root.status = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onLoadFailed: root.status = "";
|
|
||||||
}
|
// 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") : "";
|
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)
|
// Freedesktop timeout resolution, shared by the toast countdown (Toast.qml)
|
||||||
// and the no-display expiry a transient notification gets while Do Not
|
// and the no-display expiry a transient notification gets while Do Not
|
||||||
// Disturb is on (below). Critical urgency and an explicit expireTimeout
|
// 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.
|
// Without this the object is destroyed the instant this returns.
|
||||||
notification.tracked = true;
|
notification.tracked = true;
|
||||||
root.arrivals[notification.id] = new Date();
|
root.arrivals[notification.id] = new Date();
|
||||||
|
root.rememberExecCommand(notification);
|
||||||
|
|
||||||
// The object may go away at any time (app-side close, dismiss()).
|
// The object may go away at any time (app-side close, dismiss()).
|
||||||
// Drop our references synchronously when it does.
|
// Drop our references synchronously when it does.
|
||||||
@@ -610,6 +654,7 @@ Singleton {
|
|||||||
// only ever removes references, never touches the notification.
|
// only ever removes references, never touches the notification.
|
||||||
function forget(n: var): void {
|
function forget(n: var): void {
|
||||||
delete root.arrivals[n.id];
|
delete root.arrivals[n.id];
|
||||||
|
delete root.execCommands[n.id];
|
||||||
if (root.history.indexOf(n) !== -1)
|
if (root.history.indexOf(n) !== -1)
|
||||||
root.history = root.history.filter(x => x !== n);
|
root.history = root.history.filter(x => x !== n);
|
||||||
if (root.popups.indexOf(n) !== -1)
|
if (root.popups.indexOf(n) !== -1)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Singleton {
|
|||||||
{ page: "about", label: "About" },
|
{ page: "about", label: "About" },
|
||||||
{ page: "updates", label: "Software Update" },
|
{ page: "updates", label: "Software Update" },
|
||||||
{ page: "services", label: "System Health" },
|
{ page: "services", label: "System Health" },
|
||||||
|
{ page: "agents", label: "Agents" },
|
||||||
{ page: "storage", label: "Storage" },
|
{ page: "storage", label: "Storage" },
|
||||||
{ page: "snapshots", label: "Snapshots" },
|
{ page: "snapshots", label: "Snapshots" },
|
||||||
{ page: "containers", label: "Containers" },
|
{ page: "containers", label: "Containers" },
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ Singleton {
|
|||||||
"capture": "screen-intelligence",
|
"capture": "screen-intelligence",
|
||||||
"gaming": "gaming",
|
"gaming": "gaming",
|
||||||
"search": "applications",
|
"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
|
// 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: "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: "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" },
|
{ 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: "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: "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" },
|
{ 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 {
|
function onReloadFailed(error: string): void {
|
||||||
console.warn("Panama shell reload failed:", error);
|
console.warn("Panama shell reload failed:", error);
|
||||||
Quickshell.inhibitReloadPopup();
|
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, "'\\''") + "'";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
|
# @vicinae.schemaVersion 1
|
||||||
|
# @vicinae.title Settings: Agents
|
||||||
|
# @vicinae.mode silent
|
||||||
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
|
# @vicinae.description Open Agents in Settings.
|
||||||
|
# @vicinae.keywords ["settings", "preferred agent", "offer to diagnose crashes", "offer help when the shell fails to reload", "system health hands off unrepairable checks", "launched agents approve their own tools", "collect claude code usage", "collect codex usage", "refresh interval", "ai assistant", "claude code", "codex", "diagnose crashes"]
|
||||||
|
|
||||||
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page agents
|
||||||
@@ -92,6 +92,21 @@ surfacing agent usage in the bar, and a plugin registry with a lockfile. The
|
|||||||
first shipped, as a bar widget with a switch of its own on Shell › Bar; the
|
first shipped, as a bar widget with a switch of its own on Shell › Bar; the
|
||||||
second remains deferred, see below.
|
second remains deferred, see below.
|
||||||
|
|
||||||
|
**Omarchy** ([repo](https://github.com/basecamp/omarchy), MIT — DHH): the
|
||||||
|
crash-to-agent escalation ladder and the multi-agent usage collectors, both
|
||||||
|
taken as code, not just ideas, with attribution headers on every derived file.
|
||||||
|
Specifically: the command-as-notification-data hint (the shell runs the click,
|
||||||
|
so it survives shell restarts and never blocks the sender), the per-agent argv
|
||||||
|
launch table with the skill-path-as-fallback trick for harnesses without a
|
||||||
|
skill mechanism, the coredump PID+signal payload, and the Claude collector's
|
||||||
|
hard-won honesty — model-scoped limits, cached numbers that expire on window
|
||||||
|
rollover rather than age, waiting-vs-expired auth as distinct states, and
|
||||||
|
retry-on-transport-but-not-HTTP-error. The diagnose-crash skill's structure
|
||||||
|
("rule out OOM first", "never invent function names", "diagnosis reads; it
|
||||||
|
does not fix") was adapted rather than rewritten, because it was already
|
||||||
|
right. Their usage panel and menu system were not taken — Panama renders its
|
||||||
|
own surfaces in its own design language.
|
||||||
|
|
||||||
### Declined, and why
|
### Declined, and why
|
||||||
|
|
||||||
- **Wallpaper-derived dynamic color** (HyDE wallbash, matugen). The headline
|
- **Wallpaper-derived dynamic color** (HyDE wallbash, matugen). The headline
|
||||||
|
|||||||
+17
-2
@@ -4,7 +4,7 @@
|
|||||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||||
after changing the schema; a contract fails when this copy is stale.
|
after changing the schema; a contract fails when this copy is stale.
|
||||||
|
|
||||||
175 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
|
183 settings across 37 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
|
||||||
|
|
||||||
## accessibility
|
## accessibility
|
||||||
|
|
||||||
@@ -22,6 +22,21 @@ Found on **Accessibility**.
|
|||||||
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. |
|
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. |
|
||||||
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. |
|
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. |
|
||||||
|
|
||||||
|
## agents
|
||||||
|
|
||||||
|
Found on **System › Agents**.
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Preferred agent**<br>`preferredAgent` | none | Who answers when the desktop offers to investigate something Choices: None, Claude Code, Codex. |
|
||||||
|
| **Offer to diagnose crashes**<br>`crashDiagnoseOffer` | true | When a program dumps core, the notification carries a click that opens the preferred agent mid-investigation with the crash details in hand |
|
||||||
|
| **Offer help when the shell fails to reload**<br>`reloadFailureOffer` | true | A broken change to the shell's own configuration offers the failing log to the agent |
|
||||||
|
| **System Health hands off unrepairable checks**<br>`healthAgentHandoff` | true | A red check with no repair, or whose repair failed, grows an Ask-the-agent button carrying the check's snapshot |
|
||||||
|
| **Launched agents approve their own tools**<br>`agentAutoApprove` | true | 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 |
|
||||||
|
| **Collect Claude Code usage**<br>`agentUsageClaude` | true | Limits from Anthropic's usage endpoint, tokens from the local transcripts |
|
||||||
|
| **Collect Codex usage**<br>`agentUsageCodex` | true | Limits over the Codex app-server, sessions from its local files |
|
||||||
|
| **Refresh interval**<br>`agentUsageRefreshMinutes` | 15 min | How often the usage collectors ask for fresh numbers, in minutes. Range 5–60. |
|
||||||
|
|
||||||
## appearance
|
## appearance
|
||||||
|
|
||||||
Found on **Appearance**.
|
Found on **Appearance**.
|
||||||
@@ -380,7 +395,7 @@ Found on **Shell › Bar**.
|
|||||||
| **Graphics**<br>`showGpu` | true | Show graphics usage beside the workspace indicator |
|
| **Graphics**<br>`showGpu` | true | Show graphics usage beside the workspace indicator |
|
||||||
| **Battery**<br>`showBattery` | true | Show the charge level in the bar, on machines that have a battery |
|
| **Battery**<br>`showBattery` | true | Show the charge level in the bar, on machines that have a battery |
|
||||||
| **Battery percentage**<br>`showBatteryPercent` | false | Show the exact number beside the battery icon |
|
| **Battery percentage**<br>`showBatteryPercent` | false | Show the exact number beside the battery icon |
|
||||||
| **Claude usage**<br>`showAgentUsage` | false | Show how much of the Claude subscription has been used, beside the other vitals |
|
| **Agent usage**<br>`showAgentUsage` | false | Show how much of the busiest agent subscription has been used, beside the other vitals |
|
||||||
| **Vitals refresh**<br>`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. |
|
| **Vitals refresh**<br>`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. |
|
||||||
|
|
||||||
## wallpaper
|
## wallpaper
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# The escalation ladder, and usage worth trusting
|
||||||
|
|
||||||
|
Approved from mock `agents.html` (2026-08-25). Two builds in one cycle: no failure surface is a
|
||||||
|
dead end (crash → one click → your agent mid-investigation), and the bar's usage number becomes a
|
||||||
|
multi-agent panel backed by Omarchy's battle-tested collectors. Borrowed code is MIT — carry
|
||||||
|
`Copyright (c) David Heinemeier Hansson` + the MIT permission notice in a header comment on every
|
||||||
|
derived file, and add an Omarchy entry to `docs/UPSTREAM-INSPIRATION.md`.
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Scope | Ladder + usage panel, one cycle |
|
||||||
|
| Ladder surfaces v1 | App crashes, shell reload failures, Health red checks, update/migration failures, plus `panama diagnose` by hand |
|
||||||
|
| Agent | Configurable via `preferredAgent`; **Claude and Codex both fully supported on the ladder** (per-agent argv table); default `none` — quiet until chosen, like Omarchy |
|
||||||
|
| Permission posture | Auto-approve (user's explicit choice), behind a toggle; the diagnose skill still teaches reads-not-fixes; root still via `panama-sudo` |
|
||||||
|
| Usage | Collectors ported near-verbatim (Claude + Codex), Panama-native popover panel, no cross-machine sync |
|
||||||
|
| Settings home | New `agents` leaf under System |
|
||||||
|
| Notification click mechanism | Omarchy's command-as-data hint (`panama-exec`), executed by the shell — survives restarts, never blocks the watcher |
|
||||||
|
|
||||||
|
## Schema (orchestrator adds first — do not re-add)
|
||||||
|
|
||||||
|
| key | type | def | group | notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `preferredAgent` | enum `none/claude/codex` | `none` | agents | `none` = crash toasts carry no action |
|
||||||
|
| `crashDiagnoseOffer` | bool | true | agents | gates the crash rung (still requires an agent chosen) |
|
||||||
|
| `reloadFailureOffer` | bool | true | agents | shell-reload rung |
|
||||||
|
| `healthAgentHandoff` | bool | true | agents | Health-page rung |
|
||||||
|
| `agentAutoApprove` | bool | true | agents | off → agents launch in their default prompting mode |
|
||||||
|
| `agentUsageClaude` / `agentUsageCodex` | bool | true | agents | per-collector enables |
|
||||||
|
| `agentUsageRefreshMinutes` | int 5–60 | 15 | agents | collector fan-out interval |
|
||||||
|
|
||||||
|
`showAgentUsage` stays where it is (vitals) and keeps its meaning; the Bar page row remains, the
|
||||||
|
Agents page gets the full set (declared mirror in `settings-ownership-contract` if both surface
|
||||||
|
the same key). New group `agents` needs a `groupPages` route to the new leaf
|
||||||
|
(`search-routing-contract`).
|
||||||
|
|
||||||
|
## The launcher: `bin/panama-agent`
|
||||||
|
|
||||||
|
Ported from `omarchy-agent`, trimmed to Panama's needs, attribution header included.
|
||||||
|
|
||||||
|
- Reads `preferredAgent` and `agentAutoApprove` from `~/.config/panama/settings.json` with jq at
|
||||||
|
press time (the power-button precedent) — `none` exits 0 silently.
|
||||||
|
- `cd "$PANAMA_PATH"` before spawning: the checkout is where the skills, the repo trust, and the
|
||||||
|
pre-approved diagnostic permissions live.
|
||||||
|
- Per-agent argv table — **verify every flag against the installed binaries' `--help`, do not
|
||||||
|
trust the Omarchy source or this spec**: Claude ≈ `claude [auto-approve flag] -- "$prompt"`,
|
||||||
|
Codex ≈ `codex [auto-approve flag] -- "$prompt"` (Omarchy used `--permission-mode auto` and
|
||||||
|
`--approve-for-me`; use whatever the real flags are, and the prompting-mode variants when
|
||||||
|
`agentAutoApprove` is off).
|
||||||
|
- Spawn: `setsid kitty --directory "$PANAMA_PATH" -e <argv…>` (kitty is the house terminal, its
|
||||||
|
own health check exists; `--app-id`-style fixed class `panama-agent` so window rules can target
|
||||||
|
it later).
|
||||||
|
- `--prompt "text"` input; no `--pick` menu in v1 (the Agents page is the picker).
|
||||||
|
- Repo `.claude/settings.json` gains a pre-approved allowlist for the read-only diagnostic
|
||||||
|
commands (`coredumpctl`, `journalctl`, `rpm -q`, `panama doctor`) so even non-auto-approve
|
||||||
|
launches investigate smoothly.
|
||||||
|
|
||||||
|
## The rungs
|
||||||
|
|
||||||
|
**1. Crashes.** `bin/panama-crash-watch` gains `COREDUMP_PID` + `COREDUMP_SIGNAL_NAME`
|
||||||
|
extraction. When `preferredAgent != none` and `crashDiagnoseOffer`, the notification carries the
|
||||||
|
command as data: `notify-send … --hint=string:panama-exec:"$(printf 'panama-agent-crash %q %q %q %q' pid comm exe signal)"`
|
||||||
|
with body "Click to diagnose with <Agent>". Otherwise today's actionless notification, verbatim.
|
||||||
|
Existing session-dedupe and portal-crash rationale untouched. New `bin/panama-agent-crash` ports
|
||||||
|
`omarchy-agent-crash`: coredump facts + `coredumpctl list` timestamp into a heredoc prompt naming
|
||||||
|
the `diagnose-crash` skill **with its absolute path as fallback** (the harness-agnostic trick —
|
||||||
|
this is what makes the ladder work for Codex too), then `exec panama-agent --prompt "$prompt"`.
|
||||||
|
|
||||||
|
**2. The `panama-exec` hint.** `services/Notifs.qml`: read hint `panama-exec` into the
|
||||||
|
notification model (one field, carried through popup persistence like any other).
|
||||||
|
`modules/notifications/NotificationCard.qml`: on body click, if the entry carries an exec, run it
|
||||||
|
via `Quickshell.execDetached(["sh", "-c", command])` and dismiss — the branch sits beside the
|
||||||
|
existing `defaultAction` handling and defers to it when both exist. Security note in-file: the
|
||||||
|
session bus is local; any sender could carry the hint, which grants nothing an local process
|
||||||
|
lacks.
|
||||||
|
|
||||||
|
**3. Shell reload failure.** `shell.qml`'s `onReloadFailed` (old shell keeps running — it can
|
||||||
|
still notify): when `reloadFailureOffer` and an agent is chosen, send a notification through the
|
||||||
|
shell's own Notifs with the exec hint → new `bin/panama-agent-reload` builds the prompt from the
|
||||||
|
failure string plus the last ~40 matching journal lines.
|
||||||
|
|
||||||
|
**4. Health page.** Red check with no repair, or whose repair just failed → an "Ask the agent"
|
||||||
|
SettingsButton beside it (visible only when `healthAgentHandoff` and an agent chosen), running
|
||||||
|
`panama-agent --prompt` with the check's JSON snapshot (`panama doctor check <id>`) embedded.
|
||||||
|
|
||||||
|
**5. Update/migration failures.** The failure summary in `install` gains one line:
|
||||||
|
`Hand it to an agent: panama diagnose`. `bin/panama-migrate-notify`'s failure path likewise.
|
||||||
|
|
||||||
|
**6. `panama diagnose` (cmd_diagnose).** By-hand rung: bundles `panama doctor --summary`, the
|
||||||
|
last journal errors, and "what feels wrong" free text (`panama diagnose [words…]`) into a prompt
|
||||||
|
and hands it to `panama-agent`. House three-touchpoint rule + README list (readme-contract).
|
||||||
|
|
||||||
|
## The skill: `skills/diagnose-crash/`
|
||||||
|
|
||||||
|
Written by the orchestrator, shipped un-gated like the other two, structure adapted from
|
||||||
|
Omarchy's: triggers name the notification and `panama-agent-crash`; `coredumpctl info <pid>`
|
||||||
|
first and read the command line; rule out OOM before blaming the program; correlate the crash
|
||||||
|
time against recent package updates and journal context; Fedora debuginfod for symbols; the
|
||||||
|
security rule (a core holds secrets — scratch files via mktemp, deleted after); "never invent
|
||||||
|
function names"; "diagnosis reads; it does not fix"; report structure. Claims auto-pinned by
|
||||||
|
`skills-contract` (it already scans every skill in `skills/`).
|
||||||
|
|
||||||
|
## Usage collectors and panel
|
||||||
|
|
||||||
|
- `config/dot/quickshell/scripts/panama-agent-usage-claude` — port of Omarchy's Python collector
|
||||||
|
(attribution header): OAuth usage endpoint + model-scoped `limits[]`, transcript scan with
|
||||||
|
message-id dedupe and scan cache, cached-limits-expire-on-window-rollover, waiting-vs-expired
|
||||||
|
auth states, `retryAdvised` transport/HTTP distinction, percent-scale voting. Keep Panama's
|
||||||
|
secrets discipline: the token stays out of argv and out of the record (their in-process Python
|
||||||
|
read already satisfies it; the contract proves it).
|
||||||
|
- `…/panama-agent-usage-codex` — port: app-server JSON-RPC limits, session-file token stats with
|
||||||
|
the last_token_usage rule.
|
||||||
|
- `…/panama-agent-usage-update` — fan-out: runs enabled collectors in parallel, `jq -e` validity
|
||||||
|
gate, atomic writes to `$XDG_STATE_HOME/panama/agents/usage/<agent>.json`.
|
||||||
|
- `services/AgentUsage.qml` → directory model: one FileView per record file, discovered by
|
||||||
|
listing the dir; aggregate `headline` = fullest window across enabled agents; timer from
|
||||||
|
`agentUsageRefreshMinutes`, running only while `showAgentUsage`.
|
||||||
|
- `modules/bar/AgentUsageWidget.qml`: keeps the two-tier amber-75/red-90 coloring and the
|
||||||
|
hide-when-nothing rule; click now opens the panel popover (right-click keeps Settings).
|
||||||
|
- New `modules/bar/AgentUsagePanel.qml` (or popover home matching how other bar popovers are
|
||||||
|
built — follow the house pattern found in the code): per the mock — agent tabs, tier chip,
|
||||||
|
updated-ago line, limit meters with reset countdowns (model-scoped rows included), tokens by
|
||||||
|
day (today bolded), by-model bars, honest auth states. No keyboard-nav requirement in v1;
|
||||||
|
Escape closes like every popover.
|
||||||
|
- Old `scripts/panama-agent-usage` retires (delete; its consumers migrate). The five pins of
|
||||||
|
`tests/quickshell/agent-usage-contract` survive against the new collectors: token never in
|
||||||
|
argv, never in any record, credentials never written, expired token → no request + honest
|
||||||
|
state, widget hidden unless asked-for AND real numbers. Extend for: per-agent enables honored,
|
||||||
|
record validity gate, atomic write.
|
||||||
|
|
||||||
|
## New settings leaf
|
||||||
|
|
||||||
|
`agents` under the System category: 4-file ceremony (SettingsRoutes categories entry,
|
||||||
|
SettingsShell case + Component, qmldir, `AgentsPage.qml`), search entries, regen docs+commands
|
||||||
|
(orchestrator). Page per the mock: preferred-agent picker (install-state aware: show "not
|
||||||
|
installed" detail for an absent binary), the four escalation toggles (auto-approve copy states
|
||||||
|
the trade in plain words), usage card (master switch mirroring `showAgentUsage`, per-agent
|
||||||
|
enables, refresh interval).
|
||||||
|
|
||||||
|
## Contracts
|
||||||
|
|
||||||
|
- `crash-watch-contract`: extend for PID/signal fields and hint gating (none-agent → no hint;
|
||||||
|
chosen → hint present; never both mechanisms).
|
||||||
|
- `agent-usage-contract`: as above.
|
||||||
|
- New `tests/quickshell/panama-agent-contract`: hermetic — stubbed `settings.json` +
|
||||||
|
stub `kitty`/`claude`/`codex` on PATH; `none` → silent exit; claude/codex → correct argv shape
|
||||||
|
including the auto-approve/prompting variants; prompt passed as one argv element; cwd is the
|
||||||
|
repo. panama-agent-crash: prompt contains pid/comm/exe/signal + the skill path; never runs a
|
||||||
|
real agent (stub PATH). NOT in the desktop-hijacking ledger — it must run stubbed.
|
||||||
|
- `skills-contract` covers `diagnose-crash` automatically once the skill lands.
|
||||||
|
- `settings-nav`/`search-routing`/`settings-docs`/`readme-contract` obligations as usual.
|
||||||
|
|
||||||
|
## Ownership (disjoint)
|
||||||
|
|
||||||
|
- **Orchestrator**: this spec; schema keys first; the `diagnose-crash` skill;
|
||||||
|
`UPSTREAM-INSPIRATION.md` + license attribution file check; docs/commands regen; README count;
|
||||||
|
seam audit.
|
||||||
|
- **Agent A — usage**: the three collector scripts, `AgentUsage.qml`, `AgentUsageWidget.qml`,
|
||||||
|
`AgentUsagePanel.qml` (+ its qmldir if bar modules use one), `agent-usage-contract`, deletion
|
||||||
|
of the old collector.
|
||||||
|
- **Agent B — ladder scripts**: `bin/panama-agent`, `bin/panama-agent-crash`,
|
||||||
|
`bin/panama-agent-reload`, `bin/panama-crash-watch`, `bin/panama` (`cmd_diagnose`), `install`
|
||||||
|
failure line, `bin/panama-migrate-notify` failure line, `.claude/settings.json` allowlist,
|
||||||
|
`crash-watch-contract`, `panama-agent-contract`, README subcommand row.
|
||||||
|
- **Agent C — shell & settings UI**: `services/Notifs.qml` (hint field), `NotificationCard.qml`
|
||||||
|
(exec branch), `shell.qml` (`onReloadFailed` rung), `modules/settings/HealthPage.qml` ("Ask
|
||||||
|
the agent"), `AgentsPage.qml` + routes/shell-case/qmldir, search entries in
|
||||||
|
`SettingsSearch.qml`.
|
||||||
|
|
||||||
|
Standing rules apply: live desktop (valid QML every save, journal checks), no real agent
|
||||||
|
launches or notifications left uncleaned during builds, contracts as you go, never the
|
||||||
|
desktop-hijacking set unannounced.
|
||||||
@@ -328,5 +328,6 @@ else
|
|||||||
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
|
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
|
||||||
fi
|
fi
|
||||||
printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2
|
printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2
|
||||||
|
printf 'If it fails again, hand it to an agent: panama diagnose\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Remove the retired single-agent usage record. The bar's usage widget grew
|
||||||
|
# from one hardcoded Claude collector into per-agent collectors writing under
|
||||||
|
# $XDG_STATE_HOME/panama/agents/usage/, and the old record file it replaced
|
||||||
|
# would otherwise sit as dead state forever.
|
||||||
|
#
|
||||||
|
# Rules, because the runner cannot enforce them:
|
||||||
|
#
|
||||||
|
# * Safe to run twice. The marker records success, not intent.
|
||||||
|
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||||
|
# it by hand, or a later ./install may have put it back.
|
||||||
|
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||||
|
# so the password prompt names the repair.
|
||||||
|
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||||
|
# machine is genuinely in the state this describes.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
|
||||||
|
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/panama/agent-usage.json"
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: diagnose-crash
|
||||||
|
description: >
|
||||||
|
Diagnose why a program crashed on this Panama machine, from a systemd-coredump core dump.
|
||||||
|
Use when a process has segfaulted, aborted, or otherwise dumped core, when asked why an
|
||||||
|
application crashed or disappeared, when launched by panama-agent-crash, or when a
|
||||||
|
"stopped unexpectedly" desktop notification is acted on. Triggers: crash, segfault,
|
||||||
|
SIGSEGV, SIGABRT, core dump, coredumpctl, "why did X crash", "X keeps crashing",
|
||||||
|
backtrace symbolization.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Adapted from Omarchy's diagnose-crash skill (default/agents/skills/diagnose-crash/SKILL.md).
|
||||||
|
Copyright (c) David Heinemeier Hansson. MIT License: permission is hereby granted, free of
|
||||||
|
charge, to any person obtaining a copy of this software and associated documentation files,
|
||||||
|
to deal in the Software without restriction; the above copyright notice and this permission
|
||||||
|
notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Diagnosing a Crash
|
||||||
|
|
||||||
|
Work from evidence. The goal is an honest account of what happened, not a
|
||||||
|
plausible-sounding story.
|
||||||
|
|
||||||
|
## Establish the facts
|
||||||
|
|
||||||
|
`coredumpctl info <pid>` is the starting point. Beyond the backtrace, note the
|
||||||
|
**command line** the process was started with — it usually reveals what the
|
||||||
|
program was working on when it died, which is often the whole answer.
|
||||||
|
|
||||||
|
`coredumpctl list` shows whether this crash is a one-off or a pattern. Repeated
|
||||||
|
crashes of the same program, or several programs dying together, point somewhere
|
||||||
|
different than a single failure does. On this machine, Panama's System Health
|
||||||
|
page keeps a crash count too — `panama doctor check desktop.portal-stability`
|
||||||
|
knows whether the portal backend has been dying, which it chronically does.
|
||||||
|
|
||||||
|
## Rule out the boring causes first
|
||||||
|
|
||||||
|
Check resource exhaustion before blaming the program: `free -h`, and the journal
|
||||||
|
for OOM kills. A process killed by the OOM killer is not a bug in that process.
|
||||||
|
|
||||||
|
## Correlate against the timeline
|
||||||
|
|
||||||
|
The crash timestamp is the most underused piece of evidence. Compare it against:
|
||||||
|
|
||||||
|
- **Filesystem mtimes.** A file whose mtime lands on the same second as the
|
||||||
|
crash strongly suggests what triggered it.
|
||||||
|
- **The journal** around that moment, for related warnings from the same or
|
||||||
|
neighbouring processes.
|
||||||
|
- **Recent package updates.** `rpm -qa --last | head` — a crash that starts
|
||||||
|
right after an update points at the update. `dnf history` shows what a recent
|
||||||
|
transaction actually changed.
|
||||||
|
|
||||||
|
## Read the whole core, not just frame 0
|
||||||
|
|
||||||
|
Thread stacks other than the crashing one show what work was **in flight**.
|
||||||
|
That context often explains the trigger even when the crashing frame itself
|
||||||
|
cannot be symbolized. Note any third-party code in the address space —
|
||||||
|
extensions, plugins, out-of-tree drivers — but do not pin blame on it without
|
||||||
|
evidence that it is actually implicated.
|
||||||
|
|
||||||
|
## Symbolize when you can
|
||||||
|
|
||||||
|
This is Fedora, which runs a public debuginfod server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
core=$(mktemp -t crash-XXXXXX.core)
|
||||||
|
trap 'rm -f "$core"' EXIT
|
||||||
|
coredumpctl dump <pid> --output="$core"
|
||||||
|
DEBUGINFOD_URLS="https://debuginfod.fedoraproject.org/" \
|
||||||
|
gdb -q <executable> "$core" \
|
||||||
|
-batch -ex 'set debuginfod enabled on' -ex 'bt'
|
||||||
|
```
|
||||||
|
|
||||||
|
A core is a verbatim copy of the process's memory and can hold passwords,
|
||||||
|
tokens, and private documents. Write it to a fresh `mktemp` path rather than a
|
||||||
|
predictable shared one, and delete it when you are done — never leave it in
|
||||||
|
`/tmp`.
|
||||||
|
|
||||||
|
Many packages publish no debug symbols. When frames stay unresolved, say so —
|
||||||
|
**never invent function names to fill the gap.** An unsymbolized stack still
|
||||||
|
has shape: which library each frame belongs to, and whether the crash came from
|
||||||
|
a signal handler, a main loop, or a worker thread.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
1. What crashed, and what it was doing at the time.
|
||||||
|
2. The most likely mechanism — separating clearly what the evidence **proves**
|
||||||
|
from what you are **inferring**.
|
||||||
|
3. Whether any user data was lost, and where it can be recovered from. Check
|
||||||
|
the trash before concluding anything is gone.
|
||||||
|
4. Whether it is likely to recur, and what would avoid or fix it.
|
||||||
|
|
||||||
|
Be straight about the limits of the evidence. If the cause is genuinely
|
||||||
|
ambiguous, say so rather than assembling confidence out of guesswork.
|
||||||
|
|
||||||
|
**Leave the system as you found it.** Diagnosis reads; it does not fix, tidy,
|
||||||
|
or reconfigure. The one thing to clean up is your own: delete the core you
|
||||||
|
extracted above. If the user wants the fix applied, that is a second,
|
||||||
|
explicitly requested step — and anything needing root goes through
|
||||||
|
`panama-sudo --reason` (load the `panama-sudo` skill).
|
||||||
|
|
||||||
|
## If the crash is Panama's
|
||||||
|
|
||||||
|
The shell, the compositor config, and everything under `~/.local/share/Panama`
|
||||||
|
are the user's own repository — a crash there is fixable in place, not an
|
||||||
|
upstream report. Load the `panama` skill before touching the repo: it carries
|
||||||
|
the live-desktop rules that keep an investigation from becoming a second
|
||||||
|
outage.
|
||||||
@@ -178,6 +178,31 @@ ShellRoot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A command carried as data in the `panama-exec` hint -- the mechanism
|
||||||
|
// the escalation ladder rides. Three facts in one pass: a notification
|
||||||
|
// without the hint carries nothing (which is almost all of them), one
|
||||||
|
// with it carries exactly what was sent, and the entry goes when the
|
||||||
|
// notification does rather than outliving it in the model.
|
||||||
|
function execHint(): string {
|
||||||
|
root.reset();
|
||||||
|
|
||||||
|
const plain = root.notification(20, "org.exec.App.desktop", "Exec App");
|
||||||
|
Notifs.handleNotification(plain);
|
||||||
|
|
||||||
|
const carrying = root.notification(21, "org.exec.App.desktop", "Exec App");
|
||||||
|
carrying.hints = { "panama-exec": " panama-agent-crash 41283 kitty " };
|
||||||
|
Notifs.handleNotification(carrying);
|
||||||
|
const carried = Notifs.execCommand(carrying);
|
||||||
|
|
||||||
|
carrying.dismiss();
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
plain: Notifs.execCommand(plain),
|
||||||
|
carried: carried,
|
||||||
|
afterDismiss: Notifs.execCommand(carrying)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The override is one answer shared by the bell, the banner duration,
|
// The override is one answer shared by the bell, the banner duration,
|
||||||
// the breakthrough gate and the card. Only the duration is observable
|
// the breakthrough gate and the card. Only the duration is observable
|
||||||
// from here, and it is the one that would silently keep the old value.
|
// from here, and it is the one that would silently keep the old value.
|
||||||
|
|||||||
@@ -1,47 +1,149 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# How much of the Claude subscription is gone, in the bar.
|
# How much of each agent subscription is gone, in the bar.
|
||||||
#
|
#
|
||||||
# This is the only thing in Panama that reads an authentication token, so most
|
# The Claude collector is the only thing in Panama that reads an authentication
|
||||||
# of what is pinned here is about that rather than about the number:
|
# token, so most of what is pinned here is about that rather than about the
|
||||||
|
# number:
|
||||||
#
|
#
|
||||||
# 1. The token never reaches argv. A header passed as an argument is
|
# 1. The token never reaches argv. A credential passed as an argument is
|
||||||
# world-readable in /proc/<pid>/cmdline for as long as the request takes,
|
# world-readable in /proc/<pid>/cmdline for as long as the request takes,
|
||||||
# which is the same rule the password and MOK paths already follow.
|
# which is the same rule the password and MOK paths already follow. The
|
||||||
# 2. The token never reaches the output. The widget has no business seeing a
|
# collector makes its request in-process, so there is no child to leak it
|
||||||
# credential and neither does anyone reading the state file.
|
# through -- and this proves no child is spawned.
|
||||||
|
# 2. The token never reaches the output. Neither the panel nor anyone reading
|
||||||
|
# the state directory has any business seeing a credential, and neither
|
||||||
|
# does the collector's own cache.
|
||||||
# 3. It NEVER refreshes the token and never writes to the credentials file.
|
# 3. It NEVER refreshes the token and never writes to the credentials file.
|
||||||
# That token expires hourly and Claude Code refreshes it on demand; two
|
# That token expires hourly and Claude Code refreshes it on demand; two
|
||||||
# processes rotating one credential means being silently signed out of
|
# processes rotating one credential means being silently signed out of
|
||||||
# Claude Code by a status widget, and no bar indicator is worth that.
|
# Claude Code by a status widget, and no bar indicator is worth that.
|
||||||
# 4. An expired token is reported as waiting, not as an error, and no request
|
# 4. An expired token is reported honestly and no request is made with it.
|
||||||
# is made with it.
|
|
||||||
# 5. The widget hides unless it was asked for AND there are real numbers. An
|
# 5. The widget hides unless it was asked for AND there are real numbers. An
|
||||||
# indicator reading "unknown" is worse than an empty space.
|
# indicator reading "unknown" is worse than an empty space.
|
||||||
|
#
|
||||||
|
# And, since the collectors became a fan-out over a directory of records:
|
||||||
|
#
|
||||||
|
# 6. A collector the user switched off does not run, and its stale record
|
||||||
|
# does not linger.
|
||||||
|
# 7. A record is replaced only by a whole, parseable one.
|
||||||
|
# 8. The replacement is atomic -- mktemp and mv, never a redirect into the
|
||||||
|
# file a reader is watching.
|
||||||
|
#
|
||||||
|
# Hermetic throughout. The usage endpoint is a local HTTP fixture, the codex
|
||||||
|
# app-server is a stub, and the fan-out runs stub collectors: nothing here
|
||||||
|
# reaches Anthropic or starts a real agent.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
collector="$repo_dir/config/dot/quickshell/scripts/panama-agent-usage"
|
scripts="$repo_dir/config/dot/quickshell/scripts"
|
||||||
|
claude="$scripts/panama-agent-usage-claude"
|
||||||
|
codex="$scripts/panama-agent-usage-codex"
|
||||||
|
updater="$scripts/panama-agent-usage-update"
|
||||||
service="$repo_dir/config/dot/quickshell/services/AgentUsage.qml"
|
service="$repo_dir/config/dot/quickshell/services/AgentUsage.qml"
|
||||||
widget="$repo_dir/config/dot/quickshell/modules/bar/AgentUsageWidget.qml"
|
widget="$repo_dir/config/dot/quickshell/modules/bar/AgentUsageWidget.qml"
|
||||||
|
panel="$repo_dir/config/dot/quickshell/modules/bar/AgentUsagePanel.qml"
|
||||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||||
|
|
||||||
findings=()
|
findings=()
|
||||||
note() { findings+=("$1"); }
|
note() { findings+=("$1"); }
|
||||||
|
|
||||||
[[ -x "$collector" ]] || { printf 'agent usage contract: %s is not executable\n' "$collector" >&2; exit 1; }
|
for required in "$claude" "$codex" "$updater"; do
|
||||||
|
[[ -x "$required" ]] || {
|
||||||
|
printf 'agent usage contract: %s is not executable\n' "$required" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
[[ -f "$panel" ]] || { printf 'agent usage contract: missing %s\n' "$panel" >&2; exit 1; }
|
||||||
|
|
||||||
work="$(mktemp -d)"
|
work="$(mktemp -d)"
|
||||||
trap 'rm -rf "$work"' EXIT
|
server_pid=""
|
||||||
|
cleanup() {
|
||||||
|
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
stub="$work/bin"
|
stub="$work/bin"
|
||||||
mkdir -p "$stub"
|
mkdir -p "$stub" "$work/claude" "$work/cache" "$work/collectors" "$work/usage"
|
||||||
export XDG_STATE_HOME="$work/state"
|
|
||||||
output="$XDG_STATE_HOME/panama/agent-usage.json"
|
|
||||||
|
|
||||||
readonly SECRET='sk-fixture-token-must-never-appear'
|
readonly SECRET='sk-fixture-token-must-never-appear'
|
||||||
|
|
||||||
|
# ── The endpoint, served locally ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A real request to a real socket, so what the collector puts in the header is
|
||||||
|
# observable -- and so nothing in this file can accidentally reach Anthropic.
|
||||||
|
|
||||||
|
cat >"$work/server.py" <<'PY'
|
||||||
|
import http.server
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
work = sys.argv[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
with open(os.path.join(work, "headers.txt"), "a") as handle:
|
||||||
|
handle.write((self.headers.get("Authorization") or "") + "\n")
|
||||||
|
try:
|
||||||
|
status = int(open(os.path.join(work, "status")).read().strip())
|
||||||
|
except Exception:
|
||||||
|
status = 200
|
||||||
|
body = open(os.path.join(work, "payload.json"), "rb").read()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
if status == 429:
|
||||||
|
self.send_header("Retry-After", "30")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
with open(os.path.join(work, "port"), "w") as handle:
|
||||||
|
handle.write(str(server.server_address[1]))
|
||||||
|
server.serve_forever()
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf '200\n' >"$work/status"
|
||||||
|
: >"$work/headers.txt"
|
||||||
|
cat >"$work/payload.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"five_hour": {"utilization": 42, "resets_at": "2099-08-22T14:00:00Z"},
|
||||||
|
"seven_day": {"utilization": 71, "resets_at": "2099-08-27T00:00:00Z"},
|
||||||
|
"rate_limit_tier": "default_claude_max_5x",
|
||||||
|
"limits": [
|
||||||
|
{"kind": "weekly_scoped", "percent": 31, "resets_at": "2099-08-27T00:00:00Z",
|
||||||
|
"scope": {"model": {"display_name": "Fable 5", "id": "claude-fable-5"}}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
python3 "$work/server.py" "$work" &
|
||||||
|
server_pid=$!
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ -s "$work/port" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ -s "$work/port" ]] || { printf 'agent usage contract: the endpoint fixture did not start\n' >&2; exit 1; }
|
||||||
|
endpoint="http://127.0.0.1:$(cat "$work/port")/usage"
|
||||||
|
|
||||||
|
# A recording curl. Nothing should ever invoke it: the collector makes its
|
||||||
|
# request in-process. If it is called, the argv it was called with is evidence.
|
||||||
|
cat >"$stub/curl" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "\$*" >>"$work/curl-argv"
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
chmod +x "$stub/curl"
|
||||||
|
: >"$work/curl-argv"
|
||||||
|
|
||||||
credentials() {
|
credentials() {
|
||||||
local expires="$1"
|
local expires="$1"
|
||||||
cat >"$work/credentials.json" <<CREDS
|
cat >"$work/credentials.json" <<CREDS
|
||||||
@@ -50,128 +152,387 @@ credentials() {
|
|||||||
CREDS
|
CREDS
|
||||||
}
|
}
|
||||||
|
|
||||||
# Records how it was invoked, including whether the secret was in argv.
|
# The collector prints its record; the fan-out is what writes files. Both are
|
||||||
cat >"$stub/curl" <<STUB
|
# exercised, so both are seamed.
|
||||||
#!/usr/bin/env bash
|
run_claude() {
|
||||||
printf '%s\n' "\$*" >>"$work/curl-argv"
|
PATH="$stub:$PATH" \
|
||||||
printf '%s\n' '{"five_hour":{"utilization":42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}'
|
CLAUDE_CONFIG_DIR="$work/claude" \
|
||||||
STUB
|
PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \
|
||||||
chmod +x "$stub/curl"
|
PANAMA_AGENT_USAGE_ENDPOINT="${FIXTURE_ENDPOINT:-$endpoint}" \
|
||||||
|
PANAMA_AGENT_USAGE_CACHE="$work/cache" \
|
||||||
run() {
|
"$claude" "$@" 2>"$work/claude-stderr"
|
||||||
PATH="$stub:$PATH" PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \
|
|
||||||
PANAMA_AGENT_USAGE_ENDPOINT="https://example.invalid/usage" \
|
|
||||||
"$collector" >/dev/null 2>&1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 4. An expired token waits rather than failing ───────────────────────────
|
future_ms=$(( ($(date +%s) + 3600) * 1000 ))
|
||||||
|
|
||||||
|
# ── 4. An expired token is honest, and makes no request ─────────────────────
|
||||||
|
|
||||||
: >"$work/curl-argv"
|
|
||||||
credentials 1000
|
credentials 1000
|
||||||
run
|
record="$(run_claude --force)"
|
||||||
[[ "$(jq -r .status "$output")" == "stale" ]] \
|
[[ "$(jq -r .usageStatusText <<<"$record")" == "Sign-in expired" ]] \
|
||||||
|| note "an expired token did not report as stale (got: $(jq -r .status "$output"))"
|
|| note "an expired token did not report as expired (got: $(jq -r .usageStatusText <<<"$record"))"
|
||||||
[[ -s "$work/curl-argv" ]] \
|
[[ -s "$work/headers.txt" ]] \
|
||||||
&& note 'a request was made with an expired token'
|
&& note 'a request was made with an expired token'
|
||||||
|
|
||||||
# ── 1 & 2. The token stays out of argv and out of the output ────────────────
|
# A credential file that is not there at all is a different sentence.
|
||||||
|
mv "$work/credentials.json" "$work/credentials.away"
|
||||||
|
record="$(run_claude --force)"
|
||||||
|
[[ "$(jq -r .usageStatusText <<<"$record")" == "Waiting for auth" ]] \
|
||||||
|
|| note "a missing sign-in did not report as waiting (got: $(jq -r .usageStatusText <<<"$record"))"
|
||||||
|
[[ -s "$work/headers.txt" ]] \
|
||||||
|
&& note 'a request was made with no token at all'
|
||||||
|
mv "$work/credentials.away" "$work/credentials.json"
|
||||||
|
|
||||||
: >"$work/curl-argv"
|
# ── 1, 2 & 3. What happens to the token ─────────────────────────────────────
|
||||||
credentials "$(( ($(date +%s) + 3600) * 1000 ))"
|
|
||||||
run
|
|
||||||
|
|
||||||
[[ "$(jq -r .status "$output")" == "ok" ]] \
|
|
||||||
|| note "a valid token did not produce a reading (got: $(jq -r .detail "$output"))"
|
|
||||||
|
|
||||||
grep -qF "$SECRET" "$work/curl-argv" \
|
|
||||||
&& note 'the token was passed to curl as an argument, where /proc makes it world-readable'
|
|
||||||
grep -q -- '--config' "$work/curl-argv" \
|
|
||||||
|| note 'the token is not passed through a curl config file, so it may reach argv'
|
|
||||||
|
|
||||||
grep -qrF "$SECRET" "$XDG_STATE_HOME" \
|
|
||||||
&& note 'the token appears in the state file the widget reads'
|
|
||||||
|
|
||||||
# ── 3. Credentials are never written ────────────────────────────────────────
|
|
||||||
|
|
||||||
|
credentials "$future_ms"
|
||||||
before="$(sha256sum "$work/credentials.json" | cut -d' ' -f1)"
|
before="$(sha256sum "$work/credentials.json" | cut -d' ' -f1)"
|
||||||
run
|
record="$(run_claude --force)"
|
||||||
|
|
||||||
|
[[ "$(jq -r .ready <<<"$record")" == "true" ]] \
|
||||||
|
|| note "a valid token did not produce a reading (status: $(jq -r .usageStatusText <<<"$record"))"
|
||||||
|
|
||||||
|
grep -qF "Bearer $SECRET" "$work/headers.txt" \
|
||||||
|
|| note 'the token did not reach the Authorization header, so the request was not authenticated'
|
||||||
|
|
||||||
|
[[ -s "$work/curl-argv" ]] \
|
||||||
|
&& note 'the collector shelled out to curl, where /proc makes an argument world-readable'
|
||||||
|
|
||||||
|
grep -qF "$SECRET" <<<"$record" \
|
||||||
|
&& note 'the token appears in the record the panel reads'
|
||||||
|
grep -qrF "$SECRET" "$work/cache" \
|
||||||
|
&& note "the token appears in the collector's own cache"
|
||||||
|
grep -qF "$SECRET" "$work/claude-stderr" \
|
||||||
|
&& note 'the token appears on stderr, where the journal keeps it'
|
||||||
|
|
||||||
[[ "$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" == "$before" ]] \
|
[[ "$(sha256sum "$work/credentials.json" | cut -d' ' -f1)" == "$before" ]] \
|
||||||
|| note 'the collector modified the credentials file'
|
|| note 'the collector modified the credentials file'
|
||||||
|
|
||||||
# Comments stripped: the header explains at length that it does not refresh,
|
# Comments stripped: the header explains at length that it does not refresh,
|
||||||
# and matching that is matching documentation.
|
# and matching that is matching documentation.
|
||||||
uncommented() { grep -v '^[[:space:]]*#' "$1"; }
|
uncommented() { python3 - "$1" <<'PY'
|
||||||
uncommented "$collector" | grep -qE 'refreshToken|refresh_token|grant_type' \
|
import re, sys
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
# Module docstring first, then whole-line comments.
|
||||||
|
text = re.sub(r'^\s*(?:"""|\'\'\')(?:.|\n)*?(?:"""|\'\'\')', "", text, count=1)
|
||||||
|
print("\n".join(line for line in text.split("\n") if not line.lstrip().startswith("#")))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
uncommented "$claude" | grep -qE 'refreshToken|refresh_token|grant_type' \
|
||||||
&& note 'the collector touches the refresh token, which can sign Claude Code out'
|
&& note 'the collector touches the refresh token, which can sign Claude Code out'
|
||||||
uncommented "$collector" | grep -qE '>[[:space:]]*"?\$?\{?CREDENTIALS' \
|
uncommented "$claude" | grep -qE 'credentials.*\.write_text|open\([^)]*credentials[^)]*"w"' \
|
||||||
&& note 'the collector writes to the credentials file'
|
&& note 'the collector writes to the credentials file'
|
||||||
|
|
||||||
# ── The reading it produced ─────────────────────────────────────────────────
|
# ── The reading it produced ─────────────────────────────────────────────────
|
||||||
|
|
||||||
# The endpoint already reports percentages. Treating them as 0..1 fractions and
|
|
||||||
# multiplying is how the bar came to read 1500%.
|
|
||||||
[[ "$(jq -r '.usage.fiveHour.used' "$output")" == "42" ]] \
|
|
||||||
|| note "the five-hour reading is $(jq -r '.usage.fiveHour.used' "$output") for a reported 42%"
|
|
||||||
[[ "$(jq -r '.usage.week.used' "$output")" == "71" ]] \
|
|
||||||
|| note "the weekly reading is $(jq -r '.usage.week.used' "$output") for a reported 71%"
|
|
||||||
|
|
||||||
# Nothing the widget shows may fall outside the range a percentage has, whatever
|
|
||||||
# the endpoint says. A readout that can print 1500% is one you learn to ignore.
|
|
||||||
cat >"$stub/curl" <<'STUB'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' '{"five_hour":{"utilization":1500},"seven_day":{"utilization":-4}}'
|
|
||||||
STUB
|
|
||||||
chmod +x "$stub/curl"
|
|
||||||
run
|
|
||||||
[[ "$(jq -r '.usage.fiveHour.used' "$output")" == "100" ]] \
|
|
||||||
|| note 'an out-of-range reading was not clamped to 100'
|
|
||||||
[[ "$(jq -r '.usage.week.used' "$output")" == "0" ]] \
|
|
||||||
|| note 'a negative reading was not clamped to 0'
|
|
||||||
|
|
||||||
cat >"$stub/curl" <<'STUB'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf '%s\n' '{"five_hour":{"utilization":42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}'
|
|
||||||
STUB
|
|
||||||
chmod +x "$stub/curl"
|
|
||||||
|
|
||||||
# ── It answers a click ──────────────────────────────────────────────────────
|
|
||||||
#
|
#
|
||||||
# Pill's MouseArea is gated on `interactive`, so a widget that sets it false and
|
# The endpoint reports percentages already. Treating them as 0..1 fractions and
|
||||||
# connects onSecondaryActivated has a handler nothing can ever reach.
|
# multiplying is how the bar once came to read 1500%.
|
||||||
uncommented "$widget" | grep -q 'interactive: false' \
|
|
||||||
&& note 'the widget disables Pill''s mouse area, so its click handlers never fire'
|
|
||||||
uncommented "$widget" | grep -q 'onActivated' \
|
|
||||||
|| note 'left-clicking the widget does nothing'
|
|
||||||
|
|
||||||
# An endpoint that answers with something else must degrade, not crash.
|
# Whole percents: jq prints 1 and 1.0 for the same number, and a contract that
|
||||||
cat >"$stub/curl" <<'STUB'
|
# fails on the spelling of a float teaches nothing.
|
||||||
#!/usr/bin/env bash
|
percent_for() { jq -r --arg label "$1" '.limits[] | select(.label == $label) | .percent * 100 | round' <<<"$record"; }
|
||||||
printf '%s\n' '{"error":{"message":"nope"}}'
|
|
||||||
|
[[ "$(percent_for 'Session (5-hour)')" == "42" ]] \
|
||||||
|
|| note "the five-hour reading is $(percent_for 'Session (5-hour)')% for a reported 42%"
|
||||||
|
[[ "$(percent_for 'Weekly (7-day)')" == "71" ]] \
|
||||||
|
|| note "the weekly reading is $(percent_for 'Weekly (7-day)')% for a reported 71%"
|
||||||
|
|
||||||
|
# The model-scoped window lives only in the `limits` array. A collector reading
|
||||||
|
# the flat buckets alone drops a limit the account is really spending against.
|
||||||
|
[[ "$(percent_for 'Fable 5 Weekly')" == "31" ]] \
|
||||||
|
|| note 'the model-scoped limit from the limits array was not read'
|
||||||
|
|
||||||
|
[[ "$(jq -r .tierLabel <<<"$record")" == "Max 5x" ]] \
|
||||||
|
|| note "the plan label is $(jq -r .tierLabel <<<"$record"), not the Max 5x the credential names"
|
||||||
|
|
||||||
|
# Nothing the panel shows may fall outside the range a percentage has, whatever
|
||||||
|
# the endpoint says.
|
||||||
|
cat >"$work/payload.json" <<'JSON'
|
||||||
|
{"five_hour": {"utilization": 1500}, "seven_day": {"utilization": -4}}
|
||||||
|
JSON
|
||||||
|
rm -f "$work/cache/claude-limits.json"
|
||||||
|
record="$(run_claude --force)"
|
||||||
|
[[ "$(percent_for 'Session (5-hour)')" == "100" ]] \
|
||||||
|
|| note "an out-of-range reading was not clamped (got: $(percent_for 'Session (5-hour)'))"
|
||||||
|
[[ -z "$(percent_for 'Weekly (7-day)')" ]] \
|
||||||
|
|| note 'a negative reading was reported as a percentage rather than dropped'
|
||||||
|
|
||||||
|
# A payload that speaks the older fraction convention is read as fractions.
|
||||||
|
cat >"$work/payload.json" <<'JSON'
|
||||||
|
{"five_hour": {"utilization": 0.42}, "seven_day": {"utilization": 0.71}}
|
||||||
|
JSON
|
||||||
|
rm -f "$work/cache/claude-limits.json"
|
||||||
|
record="$(run_claude --force)"
|
||||||
|
[[ "$(percent_for 'Session (5-hour)')" == "42" ]] \
|
||||||
|
|| note "a fraction-scaled payload was misread as $(percent_for 'Session (5-hour)')%"
|
||||||
|
|
||||||
|
# ── A cached limit outlives its probe, but not its window ───────────────────
|
||||||
|
#
|
||||||
|
# Once a window has reset, a cached figure describes a period that is over. A
|
||||||
|
# stale 78% on a fresh week is a lie the panel must not tell.
|
||||||
|
|
||||||
|
dead_endpoint="http://127.0.0.1:1/usage"
|
||||||
|
|
||||||
|
cat >"$work/cache/claude-limits.json" <<'JSON'
|
||||||
|
{"fetchedAtMs":0,"limits":[{"label":"Weekly (7-day)","percent":0.78,"resetsAt":"2099-01-01T00:00:00+00:00"}]}
|
||||||
|
JSON
|
||||||
|
record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)"
|
||||||
|
[[ "$(percent_for 'Weekly (7-day)')" == "78" ]] \
|
||||||
|
|| note 'a cached limit whose window is still open was thrown away'
|
||||||
|
|
||||||
|
cat >"$work/cache/claude-limits.json" <<'JSON'
|
||||||
|
{"fetchedAtMs":0,"limits":[{"label":"Weekly (7-day)","percent":0.78,"resetsAt":"2000-01-01T00:00:00+00:00"}]}
|
||||||
|
JSON
|
||||||
|
record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)"
|
||||||
|
[[ -z "$(percent_for 'Weekly (7-day)')" ]] \
|
||||||
|
&& [[ "$(jq -r .usageStatusText <<<"$record")" == "Claude limits unavailable" ]] \
|
||||||
|
|| note 'a cached limit was reported after its window had already reset'
|
||||||
|
|
||||||
|
# ── A transport failure is not an answer ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Nothing reached a server: retry sooner than the interval. An HTTP status IS a
|
||||||
|
# server, and pestering it is how a rate limit becomes a ban.
|
||||||
|
|
||||||
|
rm -f "$work/cache/claude-limits.json"
|
||||||
|
record="$(FIXTURE_ENDPOINT="$dead_endpoint" run_claude --force)"
|
||||||
|
[[ "$(jq -r '.retryAdvised // false' <<<"$record")" == "true" ]] \
|
||||||
|
|| note 'a transport failure did not ask the shell to retry sooner'
|
||||||
|
|
||||||
|
printf '500\n' >"$work/status"
|
||||||
|
record="$(run_claude --force)"
|
||||||
|
[[ "$(jq -r '.retryAdvised // false' <<<"$record")" == "false" ]] \
|
||||||
|
|| note 'an HTTP error asked for a fast retry, which turns a bad status into a hammer'
|
||||||
|
|
||||||
|
printf '429\n' >"$work/status"
|
||||||
|
record="$(run_claude --force)"
|
||||||
|
grep -q 'retry after 30s' <<<"$(jq -r .authHelpText <<<"$record")" \
|
||||||
|
|| note "a 429 did not carry its retry-after into the help text (got: $(jq -r .authHelpText <<<"$record"))"
|
||||||
|
printf '200\n' >"$work/status"
|
||||||
|
|
||||||
|
# ── The Codex collector asks read-only, and reads the right field ───────────
|
||||||
|
|
||||||
|
cat >"$stub/codex" <<'STUB'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(os.environ["CODEX_STUB_ARGV"], "w") as handle:
|
||||||
|
handle.write(" ".join(sys.argv[1:]))
|
||||||
|
|
||||||
|
RESULTS = {
|
||||||
|
"initialize": {"userAgent": "stub"},
|
||||||
|
"account/read": {"account": {"type": "chatgpt", "planType": "prolite"}},
|
||||||
|
"account/rateLimits/read": {
|
||||||
|
"rateLimits": {
|
||||||
|
"planType": "prolite",
|
||||||
|
"primary": {"usedPercent": 55, "windowDurationMins": 10080, "resetsAt": 4102444800},
|
||||||
|
"secondary": None,
|
||||||
|
},
|
||||||
|
"rateLimitsByLimitId": {
|
||||||
|
"codex": {"limitName": None, "primary": {"usedPercent": 55, "windowDurationMins": 10080}},
|
||||||
|
"codex_spark": {
|
||||||
|
"limitName": "Spark",
|
||||||
|
"primary": {"usedPercent": 12, "windowDurationMins": 300, "resetsAt": 4102444800},
|
||||||
|
"secondary": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "id" not in message:
|
||||||
|
continue
|
||||||
|
print(json.dumps({"id": message["id"], "result": RESULTS.get(message.get("method"), {})}), flush=True)
|
||||||
STUB
|
STUB
|
||||||
chmod +x "$stub/curl"
|
chmod +x "$stub/codex"
|
||||||
run
|
|
||||||
[[ "$(jq -r .status "$output")" == "unavailable" ]] \
|
|
||||||
|| note 'an error response was not reported as unavailable'
|
|
||||||
|
|
||||||
# ── 5. The widget hides itself ──────────────────────────────────────────────
|
mkdir -p "$work/codex/sessions/2026/08/25"
|
||||||
|
cat >"$work/codex/sessions/2026/08/25/session.jsonl" <<'JSONL'
|
||||||
|
{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}
|
||||||
|
{"type":"token_count","timestamp":"2026-08-25T10:00:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":999999,"output_tokens":999999},"last_token_usage":{"input_tokens":1200,"cached_input_tokens":1000,"output_tokens":300}}}}
|
||||||
|
{"type":"token_count","timestamp":"2026-08-25T10:05:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1999999,"output_tokens":1999999},"last_token_usage":{"input_tokens":1200,"cached_input_tokens":1000,"output_tokens":300}}}}
|
||||||
|
JSONL
|
||||||
|
touch "$work/codex/sessions/2026/08/25/session.jsonl"
|
||||||
|
|
||||||
|
codex_record="$(
|
||||||
|
PATH="$stub:$PATH" \
|
||||||
|
CODEX_HOME="$work/codex" \
|
||||||
|
CODEX_STUB_ARGV="$work/codex-argv" \
|
||||||
|
PANAMA_AGENT_CODEX_BIN="$stub/codex" \
|
||||||
|
PANAMA_AGENT_USAGE_CACHE="$work/cache" \
|
||||||
|
"$codex" --force 2>/dev/null
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -q -- '-s read-only' "$work/codex-argv" \
|
||||||
|
|| note "the codex app-server was not asked for read-only ($(cat "$work/codex-argv" 2>/dev/null))"
|
||||||
|
grep -q -- 'app-server' "$work/codex-argv" \
|
||||||
|
|| note 'the codex collector did not start the app-server'
|
||||||
|
|
||||||
|
[[ "$(jq -r '.limits[] | select(.label == "Weekly (7-day)") | .percent * 100 | round' <<<"$codex_record")" == "55" ]] \
|
||||||
|
|| note 'the codex weekly limit was not read from the app-server'
|
||||||
|
[[ "$(jq -r '.limits[] | select(.label == "Spark Session (5-hour)") | .percent * 100 | round' <<<"$codex_record")" == "12" ]] \
|
||||||
|
|| note 'the codex model-scoped limit was not read'
|
||||||
|
[[ "$(jq -r .tierLabel <<<"$codex_record")" == "prolite" ]] \
|
||||||
|
|| note 'the codex plan was not read'
|
||||||
|
|
||||||
|
# total_token_usage is cumulative for the session. Counting it instead of
|
||||||
|
# last_token_usage makes usage grow quadratically: these two snapshots are
|
||||||
|
# 1500 tokens each, not four million.
|
||||||
|
[[ "$(jq -r .todayTotalTokens <<<"$codex_record")" == "3000" ]] \
|
||||||
|
|| note "the codex scan counted $(jq -r .todayTotalTokens <<<"$codex_record") tokens where the last-turn rule gives 3000"
|
||||||
|
|
||||||
|
# ── 6, 7 & 8. The fan-out ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cat >"$work/collectors/panama-agent-usage-claude" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'claude\n' >>"$work/ran"
|
||||||
|
printf '%s\n' '{"schemaVersion":1,"id":"claude","name":"Claude Code","ready":true,"limits":[]}'
|
||||||
|
STUB
|
||||||
|
cat >"$work/collectors/panama-agent-usage-codex" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'codex\n' >>"$work/ran"
|
||||||
|
printf '%s\n' '{"schemaVersion":1,"id":"codex","name":"Codex","ready":true,"limits":[]}'
|
||||||
|
STUB
|
||||||
|
chmod +x "$work/collectors"/panama-agent-usage-*
|
||||||
|
|
||||||
|
run_update() {
|
||||||
|
PANAMA_AGENT_USAGE_COLLECTORS="$work/collectors" \
|
||||||
|
PANAMA_AGENT_USAGE_DIR="$work/usage" \
|
||||||
|
PANAMA_SETTINGS="$work/settings.json" \
|
||||||
|
"$updater" "$@" 2>"$work/update-stderr"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both on: both records appear.
|
||||||
|
printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":true}' >"$work/settings.json"
|
||||||
|
: >"$work/ran"
|
||||||
|
run_update
|
||||||
|
[[ -s "$work/usage/claude.json" && -s "$work/usage/codex.json" ]] \
|
||||||
|
|| note 'the fan-out did not write a record for every enabled collector'
|
||||||
|
|
||||||
|
# One off: it must not run at all. "Off" has to mean "no process" -- the
|
||||||
|
# collector is the thing that reads a credential and makes a request.
|
||||||
|
printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":false}' >"$work/settings.json"
|
||||||
|
: >"$work/ran"
|
||||||
|
run_update
|
||||||
|
grep -qx 'codex' "$work/ran" \
|
||||||
|
&& note 'a collector the user switched off still ran'
|
||||||
|
[[ -e "$work/usage/codex.json" ]] \
|
||||||
|
&& note "a disabled collector's stale record was left behind for the panel to show"
|
||||||
|
|
||||||
|
# Absent means default, and the default is on. `.key // true` would read false
|
||||||
|
# as absent, which is how a switched-off collector comes back to life.
|
||||||
|
printf '%s\n' '{}' >"$work/settings.json"
|
||||||
|
: >"$work/ran"
|
||||||
|
run_update
|
||||||
|
grep -qx 'codex' "$work/ran" \
|
||||||
|
|| note 'an unset per-agent preference was not treated as its default of on'
|
||||||
|
|
||||||
|
# 7. A record is replaced only by a whole, parseable one.
|
||||||
|
printf '%s\n' '{"agentUsageClaude":true,"agentUsageCodex":true}' >"$work/settings.json"
|
||||||
|
run_update
|
||||||
|
good="$(sha256sum "$work/usage/codex.json" | cut -d' ' -f1)"
|
||||||
|
cat >"$work/collectors/panama-agent-usage-codex" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' 'Traceback (most recent call last):'
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
chmod +x "$work/collectors/panama-agent-usage-codex"
|
||||||
|
run_update
|
||||||
|
[[ "$(sha256sum "$work/usage/codex.json" | cut -d' ' -f1)" == "$good" ]] \
|
||||||
|
|| note 'a collector that died mid-run replaced a good record with rubbish'
|
||||||
|
jq -e . >/dev/null 2>&1 <"$work/usage/codex.json" \
|
||||||
|
|| note 'the usage directory holds something that is not JSON'
|
||||||
|
|
||||||
|
# 8. Atomic, and tidy: no temp file survives a failure for the panel to read.
|
||||||
|
compgen -G "$work/usage/.*.??????" >/dev/null \
|
||||||
|
&& note 'the fan-out left a temporary record behind in the directory the panel watches'
|
||||||
|
grep -qE '>[[:space:]]*"\$USAGE_DIR/\$agent\.json"' "$updater" \
|
||||||
|
&& note 'the fan-out redirects straight into the file a reader is watching'
|
||||||
|
grep -q 'mktemp' "$updater" && grep -q 'mv "\$tmp"' "$updater" \
|
||||||
|
|| note 'the fan-out does not write through mktemp and mv, so a reader can catch a half-written record'
|
||||||
|
|
||||||
|
# ── 5. The widget hides itself, and the panel is what it opens ──────────────
|
||||||
|
|
||||||
|
qml_uncommented() { grep -v '^[[:space:]]*//' "$1"; }
|
||||||
|
|
||||||
grep -q 'Settings.showAgentUsage && AgentUsage.available' "$widget" \
|
grep -q 'Settings.showAgentUsage && AgentUsage.available' "$widget" \
|
||||||
|| note 'the widget does not gate on both the preference and having real numbers'
|
|| note 'the widget does not gate on both the preference and having real numbers'
|
||||||
|
qml_uncommented "$widget" | grep -q 'interactive: false' \
|
||||||
|
&& note "the widget disables Pill's mouse area, so its click handlers never fire"
|
||||||
|
qml_uncommented "$widget" | grep -q 'onActivated' \
|
||||||
|
|| note 'left-clicking the widget does nothing'
|
||||||
|
qml_uncommented "$widget" | grep -q 'onSecondaryActivated: ShellState.openSettings' \
|
||||||
|
|| note 'right-clicking the widget no longer opens the settings that govern it'
|
||||||
|
qml_uncommented "$widget" | grep -q 'AgentUsagePanel' \
|
||||||
|
|| note 'the widget does not open the usage panel'
|
||||||
|
|
||||||
grep -q 'key: "showAgentUsage"' "$schema" || note 'there is no showAgentUsage preference'
|
grep -q 'key: "showAgentUsage"' "$schema" || note 'there is no showAgentUsage preference'
|
||||||
grep -A2 'key: "showAgentUsage"' "$schema" | grep -q 'def: false' \
|
grep -A2 'key: "showAgentUsage"' "$schema" | grep -q 'def: false' \
|
||||||
|| note 'the usage widget is on by default; it is a coding-tool readout, not general-purpose desktop furniture'
|
|| note 'the usage widget is on by default; it is a coding-tool readout, not general-purpose desktop furniture'
|
||||||
grep -q 'showAgentUsage' "$aliases" \
|
for key in showAgentUsage agentUsageClaude agentUsageCodex agentUsageRefreshMinutes; do
|
||||||
|| note 'Settings.qml does not alias showAgentUsage, so the binding reads undefined'
|
grep -q "\"$key\"" "$aliases" \
|
||||||
|
|| note "Settings.qml does not alias $key, so the binding reads undefined"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The two-tier colouring is better than one threshold and is not to be lost in
|
||||||
|
# a refactor: amber before it hurts, red when it is about to.
|
||||||
|
qml_uncommented "$widget" | grep -q 'AgentUsage.headline >= 90' \
|
||||||
|
|| note 'the widget lost its red tier at 90%'
|
||||||
|
qml_uncommented "$widget" | grep -q 'AgentUsage.headline >= 75' \
|
||||||
|
|| note 'the widget lost its amber tier at 75%'
|
||||||
|
|
||||||
|
# The collectors run on a timer measured in minutes, never on a repaint, and
|
||||||
|
# only while the readout was asked for.
|
||||||
|
python3 - "$service" <<'PY' || note 'the collectors are not run on a minute-scale timer driven by the preference'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
# The collector runs on a timer measured in minutes, never on a repaint.
|
|
||||||
python3 - "$service" <<'PY' || note 'the collector is not run on a minute-scale timer'
|
|
||||||
import re, sys
|
|
||||||
text = open(sys.argv[1], encoding="utf-8").read()
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
match = re.search(r"interval:\s*(\d+)\s*\*\s*60\s*\*\s*1000", text)
|
if not re.search(r"interval:\s*root\.refreshMinutes\s*\*\s*60\s*\*\s*1000", text):
|
||||||
if not match or int(match.group(1)) < 1:
|
raise SystemExit(1)
|
||||||
|
if "Settings.agentUsageRefreshMinutes" not in text:
|
||||||
|
raise SystemExit(1)
|
||||||
|
if not re.search(r"running:\s*Settings\.showAgentUsage", text):
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
# The panel and the service read records; neither has any business knowing what
|
||||||
|
# a credential looks like.
|
||||||
|
for file in "$service" "$panel" "$widget"; do
|
||||||
|
grep -qE 'credentials|accessToken|Authorization' "$file" \
|
||||||
|
&& note "$(basename "$file") mentions a credential; only the collector may"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Nothing in the panel repaints on a clock. The one timer it owns advances a
|
||||||
|
# displayed time and only runs while the panel is open.
|
||||||
|
python3 - "$panel" <<'PY' || note 'the panel animates or ticks while it is closed'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
for block in re.findall(r"Timer\s*\{(.*?)\n \}", text, re.S):
|
||||||
|
if "running: root.visible" not in block:
|
||||||
|
raise SystemExit(1)
|
||||||
|
if re.search(r"\b(SequentialAnimation|loops:\s*Animation\.Infinite)\b", text):
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# ── The retired collector stays retired ─────────────────────────────────────
|
||||||
|
|
||||||
|
[[ -e "$scripts/panama-agent-usage" ]] \
|
||||||
|
&& note 'the single-agent collector is still on disk; its consumers moved to the fan-out'
|
||||||
|
grep -rqF 'scripts/panama-agent-usage"' "$repo_dir/config" "$repo_dir/bin" 2>/dev/null \
|
||||||
|
&& note 'something still calls the retired single-agent collector'
|
||||||
|
|
||||||
if (( ${#findings[@]} > 0 )); then
|
if (( ${#findings[@]} > 0 )); then
|
||||||
printf 'agent usage contract: %d finding(s)\n' "${#findings[@]}" >&2
|
printf 'agent usage contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||||
printf ' - %s\n' "${findings[@]}" >&2
|
printf ' - %s\n' "${findings[@]}" >&2
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
|
|||||||
# fingerprint aliases in config/bash can rely on it without declaring it.
|
# fingerprint aliases in config/bash can rely on it without declaring it.
|
||||||
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
|
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
|
||||||
|
|
||||||
# bootctl ships in systemd-udev, which every Fedora install carries -- it is
|
# bootctl and coredumpctl ship in systemd-udev, which every Fedora install
|
||||||
# the udev half of systemd, not an optional tool.
|
# carries -- it is the udev half of systemd, not an optional tool. Declaring
|
||||||
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
# systemd-udev in a package list would state a dependency on the thing that
|
||||||
|
# boots the machine.
|
||||||
|
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||||
|
|
||||||
# Installed by install-packages itself rather than by a package list. Two
|
# Installed by install-packages itself rather than by a package list. Two
|
||||||
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
||||||
|
|||||||
@@ -463,6 +463,20 @@ jq -e '. == {
|
|||||||
before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false
|
before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false
|
||||||
}' <<<"$forgotten" >/dev/null || fail "forgetApp did not delete and restore a rule: $forgotten"
|
}' <<<"$forgotten" >/dev/null || fail "forgetApp did not delete and restore a rule: $forgotten"
|
||||||
|
|
||||||
|
# The command-as-data hint. A notification may name a shell command that the
|
||||||
|
# CARD runs on a click, which is how a crash watcher that has already exited
|
||||||
|
# still offers "diagnose this with your agent". Pinned here because the failure
|
||||||
|
# mode is silent in both directions: a hint that stops being read turns every
|
||||||
|
# rung of the escalation ladder into an ordinary notification, and an entry
|
||||||
|
# that outlives its notification hands a later click a stale command.
|
||||||
|
exec_hint="$(qs_for_test ipc call notification-app-rules-test execHint)"
|
||||||
|
jq -e '. == {
|
||||||
|
plain: "",
|
||||||
|
carried: "panama-agent-crash 41283 kitty",
|
||||||
|
afterDismiss: ""
|
||||||
|
}' <<<"$exec_hint" >/dev/null \
|
||||||
|
|| fail "the panama-exec hint is not carried through the notification model: $exec_hint"
|
||||||
|
|
||||||
# The urgency override is one answer, read by everything. The timeouts prove
|
# The urgency override is one answer, read by everything. The timeouts prove
|
||||||
# the banner duration followed it and not only the colour.
|
# the banner duration followed it and not only the colour.
|
||||||
urgency="$(qs_for_test ipc call notification-app-rules-test urgency)"
|
urgency="$(qs_for_test ipc call notification-app-rules-test urgency)"
|
||||||
|
|||||||
Executable
+366
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The launcher at the bottom of the escalation ladder.
|
||||||
|
#
|
||||||
|
# Every rung -- the crash toast, the failed reload, a red health check,
|
||||||
|
# `panama diagnose` -- ends at bin/panama-agent, which turns two settings into
|
||||||
|
# one terminal running one agent. It is the single place where the ladder can
|
||||||
|
# quietly become a no-op, or launch the wrong thing, or lose the prompt it was
|
||||||
|
# handed, so it is the single place worth pinning.
|
||||||
|
#
|
||||||
|
# What must hold:
|
||||||
|
#
|
||||||
|
# 1. "none" is silent. No agent chosen is the shipped state, not an error.
|
||||||
|
# A rung that shouted about it is a rung that gets switched off, and a
|
||||||
|
# missing settings file means the same thing as "none".
|
||||||
|
# 2. The argv per agent is the one the installed binaries actually accept.
|
||||||
|
# These flags move between releases; the table in panama-agent was read off
|
||||||
|
# `claude --help` and `codex --help`, and this pins the shape of it.
|
||||||
|
# 3. agentAutoApprove off means no mode flag at all. The agent's own default
|
||||||
|
# is a choice the user already made.
|
||||||
|
# 4. The prompt is ONE argv element. Crash and diagnose prompts are multi-line
|
||||||
|
# paragraphs; a prompt that arrives as forty words is forty words of
|
||||||
|
# nothing.
|
||||||
|
# 5. The working directory is the checkout. The skills the prompts name, the
|
||||||
|
# repository being asked about, and .claude/settings.json's pre-approved
|
||||||
|
# read-only diagnostics all live here and nowhere else.
|
||||||
|
# 6. panama-agent-crash carries all four coredump facts and the absolute path
|
||||||
|
# to the skill. The path is what makes the ladder work for an agent whose
|
||||||
|
# harness has no skill mechanism.
|
||||||
|
#
|
||||||
|
# Hermetic on purpose: kitty, claude, codex, setsid and coredumpctl are all
|
||||||
|
# stubbed onto PATH and the settings file is fabricated, so no agent is ever
|
||||||
|
# launched and no window is ever opened. This is NOT in tests/desktop-hijacking
|
||||||
|
# for exactly that reason -- it must stay runnable mid-session.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
launcher="$repo_dir/bin/panama-agent"
|
||||||
|
crash="$repo_dir/bin/panama-agent-crash"
|
||||||
|
reload="$repo_dir/bin/panama-agent-reload"
|
||||||
|
|
||||||
|
findings=()
|
||||||
|
note() { findings+=("$1"); }
|
||||||
|
|
||||||
|
for script in "$launcher" "$crash" "$reload"; do
|
||||||
|
[[ -x "$script" ]] || { printf 'panama agent contract: %s is not executable\n' "$script" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
stub="$work/bin"
|
||||||
|
mkdir -p "$stub"
|
||||||
|
settings="$work/settings.json"
|
||||||
|
|
||||||
|
# The terminal, replaced by something that records what it was asked to run.
|
||||||
|
# NUL-separated, because the prompts are multi-line and any line-oriented record
|
||||||
|
# would lose exactly the property this is here to check.
|
||||||
|
cat >"$stub/kitty" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s' "\$PWD" >"$work/cwd"
|
||||||
|
printf '%s\0' "\$@" >"$work/argv"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
# setsid is stubbed rather than real so the launch is synchronous and there is
|
||||||
|
# no race between the detached child writing and this reading it.
|
||||||
|
cat >"$stub/setsid" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
: >"$work/setsid-used"
|
||||||
|
exec "\$@"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
# Present so panama-agent's "is it installed" check passes. Reaching one of
|
||||||
|
# these means the terminal stub was bypassed, which is itself a failure.
|
||||||
|
for agent in claude codex; do
|
||||||
|
cat >"$stub/$agent" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
: >"$work/agent-actually-ran"
|
||||||
|
STUB
|
||||||
|
done
|
||||||
|
|
||||||
|
# A hand-run PID has no core here; the real one would be a slow lookup against
|
||||||
|
# this machine's journal.
|
||||||
|
cat >"$stub/coredumpctl" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
|
||||||
|
# The rungs that quote the journal get a deliberately enormous one. journalctl
|
||||||
|
# counts entries, not lines, and a real one on this machine answered a
|
||||||
|
# thirty-entry request with 2,430 lines and a quarter of a megabyte of
|
||||||
|
# backtrace -- which exec rejects outright, because a single argv element is
|
||||||
|
# capped at 128KB. The prompt builders have to bound what they quote, and this
|
||||||
|
# is what proves they do.
|
||||||
|
cat >"$stub/journalctl" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
line="quickshell: QML Item: Cannot assign to non-existent property$(printf '%0.sx' {1..500})"
|
||||||
|
for _ in {1..4000}; do printf '%s\n' "$line"; done
|
||||||
|
STUB
|
||||||
|
|
||||||
|
chmod +x "$stub"/*
|
||||||
|
|
||||||
|
# Runs the launcher hermetically and returns its exit status. argv/cwd records
|
||||||
|
# from the previous run are cleared first, so "was not launched" is a missing
|
||||||
|
# file rather than a stale one.
|
||||||
|
launch() {
|
||||||
|
rm -f "$work/argv" "$work/cwd" "$work/setsid-used" "$work/agent-actually-ran"
|
||||||
|
PATH="$stub:/usr/bin:/bin" \
|
||||||
|
PANAMA_PATH="$repo_dir" \
|
||||||
|
PANAMA_AGENT_SETTINGS="$settings" \
|
||||||
|
"$@" >"$work/stdout" 2>"$work/stderr"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The recorded argv, as an array.
|
||||||
|
recorded_argv() {
|
||||||
|
argv=()
|
||||||
|
[[ -s "$work/argv" ]] || return 1
|
||||||
|
mapfile -d '' -t argv <"$work/argv"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# The argv as one string with a separator no prompt contains, so a whole
|
||||||
|
# element can be matched without a line-oriented search.
|
||||||
|
argv_joined() {
|
||||||
|
local IFS=$'\x1f'
|
||||||
|
printf '%s' "${argv[*]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just the command kitty was told to run: everything after -e, with the
|
||||||
|
# terminal's own flags dropped. Split out because the agent's argv is what these
|
||||||
|
# checks are about, and matching it inside the whole line means writing patterns
|
||||||
|
# that would also match a --title.
|
||||||
|
agent_command() {
|
||||||
|
agent_argv=()
|
||||||
|
local element seen=0
|
||||||
|
for element in "${argv[@]}"; do
|
||||||
|
if (( seen )); then
|
||||||
|
agent_argv+=("$element")
|
||||||
|
elif [[ "$element" == "-e" ]]; then
|
||||||
|
seen=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(( ${#agent_argv[@]} > 0 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# The agent argv from element 1 on, joined -- the flags, without the resolved
|
||||||
|
# binary path, which differs per machine and is checked separately.
|
||||||
|
agent_flags() {
|
||||||
|
local IFS=$'\x1f'
|
||||||
|
printf '%s' "${agent_argv[*]:1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. "none" is silent ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"none"}\n' >"$settings"
|
||||||
|
launch "$launcher" --prompt "anything"
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
(( status == 0 )) || note "with no agent chosen panama-agent exited $status; choosing none is not an error"
|
||||||
|
[[ -s "$work/stdout" ]] && note 'with no agent chosen panama-agent printed to stdout; it is meant to be silent'
|
||||||
|
[[ -s "$work/stderr" ]] && note "with no agent chosen panama-agent complained: $(head -1 "$work/stderr")"
|
||||||
|
[[ -e "$work/argv" ]] && note 'with no agent chosen panama-agent still opened a terminal'
|
||||||
|
|
||||||
|
# A machine that has never been asked the question answers the same way.
|
||||||
|
rm -f "$settings"
|
||||||
|
launch "$launcher" --prompt "anything"
|
||||||
|
status=$?
|
||||||
|
(( status == 0 )) || note "with no settings file panama-agent exited $status; the default is none, which is silent"
|
||||||
|
[[ -e "$work/argv" ]] && note 'with no settings file panama-agent opened a terminal anyway'
|
||||||
|
|
||||||
|
# ── 2, 4, 5. Claude, auto-approving ─────────────────────────────────────────
|
||||||
|
|
||||||
|
prompt=$'first line\nsecond line with spaces'
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","agentAutoApprove":true}\n' >"$settings"
|
||||||
|
launch "$launcher" --prompt "$prompt"
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
(( status == 0 )) || note "panama-agent exited $status launching claude"
|
||||||
|
[[ -e "$work/setsid-used" ]] || note 'the launch does not go through setsid, so the agent dies with whatever spawned it'
|
||||||
|
[[ -e "$work/agent-actually-ran" ]] && note 'the contract reached a real agent binary; it must stop at the terminal stub'
|
||||||
|
|
||||||
|
if recorded_argv && agent_command; then
|
||||||
|
joined="$(argv_joined)"
|
||||||
|
|
||||||
|
[[ "$joined" == *$'\x1f'"--class"$'\x1f'"panama-agent"* ]] \
|
||||||
|
|| note 'the terminal is not given the fixed panama-agent window class, so no window rule can find it'
|
||||||
|
[[ "$joined" == *"--directory"$'\x1f'"$repo_dir"* ]] \
|
||||||
|
|| note 'the terminal is not opened in the Panama checkout'
|
||||||
|
|
||||||
|
# The binary is resolved here, not left for kitty to find. The click that
|
||||||
|
# reaches this script comes from the shell, whose PATH does not contain
|
||||||
|
# ~/.local/bin, and kitty would inherit exactly that PATH.
|
||||||
|
[[ "${agent_argv[0]}" == /* ]] \
|
||||||
|
|| note "the agent is passed to the terminal as '${agent_argv[0]}' rather than a resolved path"
|
||||||
|
[[ "${agent_argv[0]}" == */claude ]] \
|
||||||
|
|| note "the resolved binary is ${agent_argv[0]}, which is not claude"
|
||||||
|
|
||||||
|
[[ "$(agent_flags)" == "--permission-mode"$'\x1f'"auto"$'\x1f'"--"$'\x1f'"$prompt" ]] \
|
||||||
|
|| note "claude with auto-approve on is launched as: $(agent_flags)"
|
||||||
|
|
||||||
|
# 4. The prompt survives as one element, newlines and all.
|
||||||
|
[[ "${argv[-1]}" == "$prompt" ]] \
|
||||||
|
|| note 'the prompt did not arrive as a single argv element'
|
||||||
|
|
||||||
|
# 5. Not just named as a flag -- actually the working directory.
|
||||||
|
[[ "$(cat "$work/cwd" 2>/dev/null)" == "$repo_dir" ]] \
|
||||||
|
|| note "the agent starts in $(cat "$work/cwd" 2>/dev/null), not in the checkout"
|
||||||
|
else
|
||||||
|
note 'launching claude opened no terminal at all'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Claude, prompting normally ───────────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","agentAutoApprove":false}\n' >"$settings"
|
||||||
|
launch "$launcher" --prompt "$prompt"
|
||||||
|
|
||||||
|
if recorded_argv && agent_command; then
|
||||||
|
[[ "$(agent_flags)" == *"--permission-mode"* ]] \
|
||||||
|
&& note 'agentAutoApprove is off and claude was still given a permission mode'
|
||||||
|
[[ "$(agent_flags)" == "--"$'\x1f'"$prompt" ]] \
|
||||||
|
|| note "claude with auto-approve off is launched as: $(agent_flags)"
|
||||||
|
else
|
||||||
|
note 'launching claude with auto-approve off opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2 & 3. Codex, both ways ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"codex","agentAutoApprove":true}\n' >"$settings"
|
||||||
|
launch "$launcher" --prompt "$prompt"
|
||||||
|
|
||||||
|
if recorded_argv && agent_command; then
|
||||||
|
[[ "${agent_argv[0]}" == */codex ]] \
|
||||||
|
|| note "the resolved binary is ${agent_argv[0]}, which is not codex"
|
||||||
|
[[ "$(agent_flags)" == "--approve-for-me"$'\x1f'"--"$'\x1f'"$prompt" ]] \
|
||||||
|
|| note "codex with auto-approve on is launched as: $(agent_flags)"
|
||||||
|
[[ "${argv[-1]}" == "$prompt" ]] \
|
||||||
|
|| note 'codex did not receive the prompt as a single argv element'
|
||||||
|
else
|
||||||
|
note 'launching codex opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"codex","agentAutoApprove":false}\n' >"$settings"
|
||||||
|
launch "$launcher" --prompt "$prompt"
|
||||||
|
|
||||||
|
if recorded_argv && agent_command; then
|
||||||
|
[[ "$(agent_flags)" == *"--approve-for-me"* ]] \
|
||||||
|
&& note 'agentAutoApprove is off and codex was still told to approve for itself'
|
||||||
|
[[ "$(agent_flags)" == "--"$'\x1f'"$prompt" ]] \
|
||||||
|
|| note "codex with auto-approve off is launched as: $(agent_flags)"
|
||||||
|
else
|
||||||
|
note 'launching codex with auto-approve off opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── An agent installed where only the user's own PATH looks ─────────────────
|
||||||
|
#
|
||||||
|
# Both agents install themselves into ~/.local/bin. The rungs are clicked from
|
||||||
|
# the shell, and systemd starts the shell with a PATH that does not contain it,
|
||||||
|
# so a launcher that trusted the inherited PATH would report every installed
|
||||||
|
# agent as missing -- from a detached process, into a stderr nobody reads.
|
||||||
|
|
||||||
|
home="$work/home"
|
||||||
|
mkdir -p "$home/.local/bin" "$work/bin-noagent" "$work/empty"
|
||||||
|
cp "$stub/kitty" "$stub/setsid" "$work/bin-noagent/"
|
||||||
|
cp "$stub/claude" "$home/.local/bin/claude"
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","agentAutoApprove":true}\n' >"$settings"
|
||||||
|
rm -f "$work/argv" "$work/cwd"
|
||||||
|
HOME="$home" PATH="$work/bin-noagent:/usr/bin:/bin" \
|
||||||
|
PANAMA_PATH="$repo_dir" PANAMA_AGENT_SETTINGS="$settings" \
|
||||||
|
"$launcher" --prompt "$prompt" >"$work/stdout" 2>"$work/stderr"
|
||||||
|
status=$?
|
||||||
|
(( status == 0 )) || note "an agent installed in ~/.local/bin was not found: $(head -1 "$work/stderr")"
|
||||||
|
|
||||||
|
if recorded_argv && agent_command; then
|
||||||
|
[[ "${agent_argv[0]}" == "$home/.local/bin/claude" ]] \
|
||||||
|
|| note "an agent reachable only through ~/.local/bin resolved to ${agent_argv[0]}"
|
||||||
|
else
|
||||||
|
note 'an agent installed in ~/.local/bin opened no terminal, so every click from the shell would be a dead end'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── An agent that is genuinely not installed says so ────────────────────────
|
||||||
|
|
||||||
|
rm -f "$work/argv" "$work/cwd"
|
||||||
|
HOME="$work/empty" PATH="$work/empty:/usr/bin:/bin" \
|
||||||
|
PANAMA_PATH="$repo_dir" PANAMA_AGENT_SETTINGS="$settings" \
|
||||||
|
"$launcher" --prompt "$prompt" >"$work/stdout" 2>"$work/stderr"
|
||||||
|
status=$?
|
||||||
|
(( status != 0 )) || note 'a preferred agent that is not installed exited 0, so the rung failed silently'
|
||||||
|
grep -qi 'not installed' "$work/stderr" \
|
||||||
|
|| note 'a missing agent binary produced no explanation naming it'
|
||||||
|
|
||||||
|
# ── 6. The crash prompt carries the facts ───────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","agentAutoApprove":true}\n' >"$settings"
|
||||||
|
launch "$crash" 4242 panama-test-cra /usr/bin/panama-test-crasher SIGSEGV
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
(( status == 0 )) || note "panama-agent-crash exited $status"
|
||||||
|
[[ -e "$work/agent-actually-ran" ]] && note 'panama-agent-crash reached a real agent binary'
|
||||||
|
|
||||||
|
if recorded_argv; then
|
||||||
|
crash_prompt="${argv[-1]}"
|
||||||
|
for fact in 4242 panama-test-cra /usr/bin/panama-test-crasher SIGSEGV; do
|
||||||
|
[[ "$crash_prompt" == *"$fact"* ]] \
|
||||||
|
|| note "the crash prompt does not mention $fact, which a diagnosis needs"
|
||||||
|
done
|
||||||
|
[[ "$crash_prompt" == *"$repo_dir/skills/diagnose-crash/SKILL.md"* ]] \
|
||||||
|
|| note 'the crash prompt does not give the absolute path to the diagnose-crash skill, so an agent without a skill mechanism has nothing to read'
|
||||||
|
[[ -r "$repo_dir/skills/diagnose-crash/SKILL.md" ]] \
|
||||||
|
|| note 'the path the crash prompt points at does not exist'
|
||||||
|
else
|
||||||
|
note 'panama-agent-crash opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Something that is not a PID is a usage error, not a prompt about "unknown".
|
||||||
|
launch "$crash" not-a-pid
|
||||||
|
status=$?
|
||||||
|
(( status != 0 )) || note 'panama-agent-crash accepted a non-PID and launched anyway'
|
||||||
|
[[ -e "$work/argv" ]] && note 'panama-agent-crash launched an agent for a non-PID'
|
||||||
|
|
||||||
|
# ── The reload prompt ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
launch "$reload" "shell.qml:42 Cannot assign to non-existent property"
|
||||||
|
status=$?
|
||||||
|
(( status == 0 )) || note "panama-agent-reload exited $status"
|
||||||
|
|
||||||
|
if recorded_argv; then
|
||||||
|
[[ "${argv[-1]}" == *"Cannot assign to non-existent property"* ]] \
|
||||||
|
|| note 'the reload prompt does not carry what Quickshell reported'
|
||||||
|
else
|
||||||
|
note 'panama-agent-reload opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
launch "$reload"
|
||||||
|
status=$?
|
||||||
|
(( status != 0 )) || note 'panama-agent-reload with nothing to report launched an agent anyway'
|
||||||
|
|
||||||
|
# ── The by-hand rung ────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# `panama diagnose` quotes the journal too, and quoted the whole of it once:
|
||||||
|
# exec answered with "Argument list too long" and the rung did nothing at all.
|
||||||
|
# The health summary here is the real one, which is read-only and takes about a
|
||||||
|
# second.
|
||||||
|
|
||||||
|
launch "$repo_dir/bin/panama" diagnose the bar disappears after unplugging
|
||||||
|
status=$?
|
||||||
|
(( status == 0 )) || note "panama diagnose exited $status: $(head -1 "$work/stderr")"
|
||||||
|
|
||||||
|
if recorded_argv; then
|
||||||
|
diagnose_prompt="${argv[-1]}"
|
||||||
|
(( ${#diagnose_prompt} < 131072 )) \
|
||||||
|
|| note "the diagnose prompt is ${#diagnose_prompt} bytes; one argv element caps at 128KB and exec would refuse it"
|
||||||
|
[[ "$diagnose_prompt" == *"the bar disappears after unplugging"* ]] \
|
||||||
|
|| note 'the diagnose prompt dropped the words the person typed, which are the part no collector reports'
|
||||||
|
else
|
||||||
|
note 'panama diagnose opened no terminal'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#findings[@]} > 0 )); then
|
||||||
|
printf 'panama agent contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||||
|
printf ' - %s\n' "${findings[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'panama agent contract: PASS\n'
|
||||||
@@ -125,22 +125,28 @@ while read -r pair; do
|
|||||||
fi
|
fi
|
||||||
done <<<"$retired_pairs"
|
done <<<"$retired_pairs"
|
||||||
|
|
||||||
# ── The System strip is eight tabs, and the two it lost are still reachable ──
|
# ── The System strip is nine tabs, and the two it lost are still reachable ───
|
||||||
#
|
#
|
||||||
# System had grown to ten tabs, which is more than a strip can show without
|
# System had grown to ten tabs, which is more than a strip can show without
|
||||||
# becoming a second sidebar. Two left: Region & Language merged into Date &
|
# becoming a second sidebar. Two left: Region & Language merged into Date &
|
||||||
# Time, because a date format and the clock that shows it are one subject, and
|
# Time, because a date format and the clock that shows it are one subject, and
|
||||||
# the Manual became a hidden leaf.
|
# the Manual became a hidden leaf.
|
||||||
#
|
#
|
||||||
|
# Agents is the ninth, added deliberately rather than by accretion: it is where
|
||||||
|
# the preferred agent is chosen, and every rung of the escalation ladder --
|
||||||
|
# crash notifications, failed reloads, red health checks -- is dark until that
|
||||||
|
# choice is made, so it has to be somewhere a person can find without being
|
||||||
|
# told the id.
|
||||||
|
#
|
||||||
# The count is pinned rather than derived because the number is the point: this
|
# The count is pinned rather than derived because the number is the point: this
|
||||||
# is the horizontal space one row of tabs has. Anything that needs an eleventh
|
# is the horizontal space one row of tabs has. Anything that needs a tenth
|
||||||
# subject needs a decision, not another entry.
|
# subject needs a decision, not another entry.
|
||||||
python3 - "$routes" <<'PY' || fail 'the System category is not the approved eight-tab strip'
|
python3 - "$routes" <<'PY' || fail 'the System category is not the approved nine-tab strip'
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
expected = [
|
expected = [
|
||||||
"about", "updates", "services", "storage",
|
"about", "updates", "services", "agents", "storage",
|
||||||
"snapshots", "containers", "datetime", "sync",
|
"snapshots", "containers", "datetime", "sync",
|
||||||
]
|
]
|
||||||
text = open(sys.argv[1], encoding="utf-8").read()
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ expected = {
|
|||||||
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
||||||
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
||||||
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
|
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||||
|
# The bar decides what the bar contains, which is why Bar owns this. Agents
|
||||||
|
# mirrors it because that is the page somebody is on when they wonder where
|
||||||
|
# the number went -- the rest of the usage settings (which collectors run,
|
||||||
|
# how often) live only there, and a master switch missing from the card
|
||||||
|
# that holds them would be a card that cannot be turned off from itself.
|
||||||
|
"showAgentUsage": {"owner": "bar", "mirrors": {"agents"}},
|
||||||
# lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was
|
# lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was
|
||||||
# the one mirror in this table that nobody had asked for: Privacy carried a
|
# the one mirror in this table that nobody had asked for: Privacy carried a
|
||||||
# whole second Screen-lock card, so the same preference had two sliders and
|
# whole second Screen-lock card, so the same preference had two sliders and
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ fail() {
|
|||||||
# convention the scaffold carries -- the scroll behaviour, the header, the
|
# convention the scaffold carries -- the scroll behaviour, the header, the
|
||||||
# padding -- was hand-rolled there and quietly different from the other
|
# padding -- was hand-rolled there and quietly different from the other
|
||||||
# twenty-five pages.
|
# twenty-five pages.
|
||||||
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About Updates DateTime Containers Manual)
|
# Agents joined on the day it was written, which is the only moment a page has
|
||||||
|
# never had a hand-rolled scaffold in it.
|
||||||
|
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health Agents About Updates DateTime Containers Manual)
|
||||||
for page in "${pages[@]}"; do
|
for page in "${pages[@]}"; do
|
||||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||||
|
|||||||
@@ -23,6 +23,23 @@
|
|||||||
# 5. It follows from now rather than replaying the boot, so a session that
|
# 5. It follows from now rather than replaying the boot, so a session that
|
||||||
# starts after a crash does not open with a notification about something
|
# starts after a crash does not open with a notification about something
|
||||||
# already lived through.
|
# already lived through.
|
||||||
|
#
|
||||||
|
# And, since the escalation ladder landed:
|
||||||
|
#
|
||||||
|
# 6. With no agent chosen, the notification is exactly what it always was:
|
||||||
|
# no action, no hint, no promise it cannot keep. "none" is the shipped
|
||||||
|
# state and the quiet one.
|
||||||
|
# 7. With an agent chosen, the click payload is carried as data in a
|
||||||
|
# `panama-exec` hint -- never as a libnotify action, which would tie the
|
||||||
|
# click to this long-lived `journalctl -f` still being alive to hear it --
|
||||||
|
# and it carries the PID and the signal, which are the two facts a
|
||||||
|
# diagnosis cannot start without.
|
||||||
|
# 8. The body names the agent, because "diagnose with AI" tells nobody what
|
||||||
|
# is about to open.
|
||||||
|
# 9. The offer can be switched off on its own, without switching the crash
|
||||||
|
# report off with it.
|
||||||
|
# 10. The watcher never announces the agent it just launched. A crash watcher
|
||||||
|
# that notifies about panama-agent is a loop with a toast in it.
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -42,17 +59,19 @@ calls="$work/calls"
|
|||||||
stub="$work/bin"
|
stub="$work/bin"
|
||||||
mkdir -p "$stub"
|
mkdir -p "$stub"
|
||||||
|
|
||||||
# Two crashes of one program, one of another, and one belonging to somebody
|
# Two crashes of one program, one of another, one belonging to somebody else,
|
||||||
# else. journalctl is replaced by a stub that emits them and exits, so the
|
# and one from the ladder's own machinery. journalctl is replaced by a stub that
|
||||||
# watcher's follow loop terminates instead of hanging the test.
|
# emits them and exits, so the watcher's follow loop terminates instead of
|
||||||
|
# hanging the test.
|
||||||
uid="$(id -u)"
|
uid="$(id -u)"
|
||||||
cat >"$stub/journalctl" <<STUB
|
cat >"$stub/journalctl" <<STUB
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' \\
|
printf '%s\n' \\
|
||||||
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/panama-test-crasher","COREDUMP_COMM":"panama-test-cra"}' \\
|
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/panama-test-crasher","COREDUMP_COMM":"panama-test-cra","COREDUMP_PID":"4242","COREDUMP_SIGNAL_NAME":"SIGSEGV"}' \\
|
||||||
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/panama-test-crasher","COREDUMP_COMM":"panama-test-cra"}' \\
|
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/panama-test-crasher","COREDUMP_COMM":"panama-test-cra","COREDUMP_PID":"4243","COREDUMP_SIGNAL_NAME":"SIGSEGV"}' \\
|
||||||
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/other-program","COREDUMP_COMM":"other-program"}' \\
|
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"/usr/bin/other-program","COREDUMP_COMM":"other-program","COREDUMP_PID":"4244","COREDUMP_SIGNAL_NAME":"SIGABRT"}' \\
|
||||||
'{"COREDUMP_UID":"99999","COREDUMP_EXE":"/usr/bin/someone-elses","COREDUMP_COMM":"someone-elses"}'
|
'{"COREDUMP_UID":"99999","COREDUMP_EXE":"/usr/bin/someone-elses","COREDUMP_COMM":"someone-elses","COREDUMP_PID":"4245","COREDUMP_SIGNAL_NAME":"SIGSEGV"}' \\
|
||||||
|
'{"COREDUMP_UID":"$uid","COREDUMP_EXE":"$repo_dir/bin/panama-agent-crash","COREDUMP_COMM":"panama-agent-cr","COREDUMP_PID":"4246","COREDUMP_SIGNAL_NAME":"SIGSEGV"}'
|
||||||
STUB
|
STUB
|
||||||
chmod +x "$stub/journalctl"
|
chmod +x "$stub/journalctl"
|
||||||
|
|
||||||
@@ -69,8 +88,22 @@ exit 0
|
|||||||
STUB
|
STUB
|
||||||
chmod +x "$stub/busctl"
|
chmod +x "$stub/busctl"
|
||||||
|
|
||||||
: >"$calls"
|
# Fabricated settings rather than this machine's, so the contract's answer does
|
||||||
PATH="$stub:$PATH" timeout 20 "$watcher" >/dev/null 2>&1
|
# not depend on which agent the person running it happens to prefer.
|
||||||
|
settings="$work/settings.json"
|
||||||
|
|
||||||
|
# Runs the watcher once against a given settings file and returns what it asked
|
||||||
|
# notify-send for.
|
||||||
|
run_watcher() {
|
||||||
|
: >"$calls"
|
||||||
|
PATH="$stub:$PATH" PANAMA_PATH="$repo_dir" PANAMA_AGENT_SETTINGS="$settings" \
|
||||||
|
timeout 20 "$watcher" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── The shipped state: no agent ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"none"}\n' >"$settings"
|
||||||
|
run_watcher
|
||||||
|
|
||||||
# ── 1. Once per program ─────────────────────────────────────────────────────
|
# ── 1. Once per program ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,6 +125,58 @@ grep -q 'someone-elses' "$calls" \
|
|||||||
grep -q 'panama-test-cra ' "$calls" \
|
grep -q 'panama-test-cra ' "$calls" \
|
||||||
&& note 'the notification uses the truncated kernel comm field rather than the executable name'
|
&& note 'the notification uses the truncated kernel comm field rather than the executable name'
|
||||||
|
|
||||||
|
# ── 6. No agent means no offer ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
grep -q 'panama-exec' "$calls" \
|
||||||
|
&& note 'with no agent chosen the notification still carries a diagnose command, which would click into nothing'
|
||||||
|
grep -q 'System Health has the details' "$calls" \
|
||||||
|
|| note 'with no agent chosen the notification lost its plain body'
|
||||||
|
|
||||||
|
# ── 10. Never its own machinery ─────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Five entries go in; two programs come out. The third crash belongs to another
|
||||||
|
# user, the fourth is the duplicate, and the fifth is panama-agent-crash itself.
|
||||||
|
|
||||||
|
grep -q 'panama-agent' "$calls" \
|
||||||
|
&& note 'a crash in the ladder machinery was announced, which is how a crash loop becomes a notification loop'
|
||||||
|
sent="$(wc -l <"$calls")"
|
||||||
|
(( sent == 2 )) \
|
||||||
|
|| note "with no agent chosen the watcher sent $sent notifications for five journal entries; two are warranted"
|
||||||
|
|
||||||
|
# ── 7 & 8. An agent chosen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","crashDiagnoseOffer":true}\n' >"$settings"
|
||||||
|
run_watcher
|
||||||
|
|
||||||
|
offer="$(grep 'panama-test-crasher' "$calls" | head -1)"
|
||||||
|
|
||||||
|
[[ "$offer" == *"panama-exec"* ]] \
|
||||||
|
|| note 'with an agent chosen the notification carries no panama-exec hint, so the click has nothing to run'
|
||||||
|
[[ "$offer" == *"$repo_dir/bin/panama-agent-crash"* ]] \
|
||||||
|
|| note 'the hint does not invoke panama-agent-crash by absolute path; the shell that runs the click is started by systemd and has no repo bin on PATH'
|
||||||
|
[[ "$offer" == *"4242"* ]] \
|
||||||
|
|| note 'the hint carries no PID; coredumpctl cannot be asked about a crash without one'
|
||||||
|
[[ "$offer" == *"SIGSEGV"* ]] \
|
||||||
|
|| note 'the hint carries no signal name, which is the first thing a diagnosis reads'
|
||||||
|
[[ "$offer" == *"Claude Code"* ]] \
|
||||||
|
|| note 'the body does not name the agent, so the click does not say what it opens'
|
||||||
|
[[ "$offer" == *"--action"* ]] \
|
||||||
|
&& note 'a libnotify action was used as well; the click must come back through the hint alone'
|
||||||
|
|
||||||
|
sent="$(wc -l <"$calls")"
|
||||||
|
(( sent == 2 )) \
|
||||||
|
|| note "with an agent chosen the watcher sent $sent notifications for five journal entries; two are warranted"
|
||||||
|
|
||||||
|
# ── 9. The offer switches off on its own ────────────────────────────────────
|
||||||
|
|
||||||
|
printf '{"preferredAgent":"claude","crashDiagnoseOffer":false}\n' >"$settings"
|
||||||
|
run_watcher
|
||||||
|
|
||||||
|
grep -q 'panama-exec' "$calls" \
|
||||||
|
&& note 'crashDiagnoseOffer=false still offered a diagnosis'
|
||||||
|
grep -q 'panama-test-crasher' "$calls" \
|
||||||
|
|| note 'switching the offer off also switched the crash report off; they are separate things'
|
||||||
|
|
||||||
# ── 4 & 5. How it listens ───────────────────────────────────────────────────
|
# ── 4 & 5. How it listens ───────────────────────────────────────────────────
|
||||||
|
|
||||||
grep -q 'org.freedesktop.Notifications' "$watcher" \
|
grep -q 'org.freedesktop.Notifications' "$watcher" \
|
||||||
|
|||||||
Reference in New Issue
Block a user