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:
@@ -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 breakthrough gate and the card. Only the duration is observable
|
||||
// from here, and it is the one that would silently keep the old value.
|
||||
|
||||
@@ -1,47 +1,149 @@
|
||||
#!/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
|
||||
# of what is pinned here is about that rather than about the number:
|
||||
# The Claude collector is the only thing in Panama that reads an authentication
|
||||
# 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,
|
||||
# which is the same rule the password and MOK paths already follow.
|
||||
# 2. The token never reaches the output. The widget has no business seeing a
|
||||
# credential and neither does anyone reading the state file.
|
||||
# which is the same rule the password and MOK paths already follow. The
|
||||
# collector makes its request in-process, so there is no child to leak it
|
||||
# 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.
|
||||
# That token expires hourly and Claude Code refreshes it on demand; two
|
||||
# processes rotating one credential means being silently signed out of
|
||||
# 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
|
||||
# is made with it.
|
||||
# 4. An expired token is reported honestly and no request is made with it.
|
||||
# 5. The widget hides unless it was asked for AND there are real numbers. An
|
||||
# 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
|
||||
|
||||
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"
|
||||
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"
|
||||
aliases="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||
|
||||
findings=()
|
||||
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)"
|
||||
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"
|
||||
mkdir -p "$stub"
|
||||
export XDG_STATE_HOME="$work/state"
|
||||
output="$XDG_STATE_HOME/panama/agent-usage.json"
|
||||
mkdir -p "$stub" "$work/claude" "$work/cache" "$work/collectors" "$work/usage"
|
||||
|
||||
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() {
|
||||
local expires="$1"
|
||||
cat >"$work/credentials.json" <<CREDS
|
||||
@@ -50,128 +152,387 @@ credentials() {
|
||||
CREDS
|
||||
}
|
||||
|
||||
# Records how it was invoked, including whether the secret was in argv.
|
||||
cat >"$stub/curl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$*" >>"$work/curl-argv"
|
||||
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"
|
||||
|
||||
run() {
|
||||
PATH="$stub:$PATH" PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \
|
||||
PANAMA_AGENT_USAGE_ENDPOINT="https://example.invalid/usage" \
|
||||
"$collector" >/dev/null 2>&1
|
||||
# The collector prints its record; the fan-out is what writes files. Both are
|
||||
# exercised, so both are seamed.
|
||||
run_claude() {
|
||||
PATH="$stub:$PATH" \
|
||||
CLAUDE_CONFIG_DIR="$work/claude" \
|
||||
PANAMA_AGENT_CREDENTIALS="$work/credentials.json" \
|
||||
PANAMA_AGENT_USAGE_ENDPOINT="${FIXTURE_ENDPOINT:-$endpoint}" \
|
||||
PANAMA_AGENT_USAGE_CACHE="$work/cache" \
|
||||
"$claude" "$@" 2>"$work/claude-stderr"
|
||||
}
|
||||
|
||||
# ── 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
|
||||
run
|
||||
[[ "$(jq -r .status "$output")" == "stale" ]] \
|
||||
|| note "an expired token did not report as stale (got: $(jq -r .status "$output"))"
|
||||
[[ -s "$work/curl-argv" ]] \
|
||||
record="$(run_claude --force)"
|
||||
[[ "$(jq -r .usageStatusText <<<"$record")" == "Sign-in expired" ]] \
|
||||
|| note "an expired token did not report as expired (got: $(jq -r .usageStatusText <<<"$record"))"
|
||||
[[ -s "$work/headers.txt" ]] \
|
||||
&& 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"
|
||||
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 ────────────────────────────────────────
|
||||
# ── 1, 2 & 3. What happens to the token ─────────────────────────────────────
|
||||
|
||||
credentials "$future_ms"
|
||||
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" ]] \
|
||||
|| note 'the collector modified the credentials file'
|
||||
|
||||
# Comments stripped: the header explains at length that it does not refresh,
|
||||
# and matching that is matching documentation.
|
||||
uncommented() { grep -v '^[[:space:]]*#' "$1"; }
|
||||
uncommented "$collector" | grep -qE 'refreshToken|refresh_token|grant_type' \
|
||||
uncommented() { python3 - "$1" <<'PY'
|
||||
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'
|
||||
uncommented "$collector" | grep -qE '>[[:space:]]*"?\$?\{?CREDENTIALS' \
|
||||
uncommented "$claude" | grep -qE 'credentials.*\.write_text|open\([^)]*credentials[^)]*"w"' \
|
||||
&& note 'the collector writes to the credentials file'
|
||||
|
||||
# ── 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
|
||||
# connects onSecondaryActivated has a handler nothing can ever reach.
|
||||
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'
|
||||
# The endpoint reports percentages already. Treating them as 0..1 fractions and
|
||||
# multiplying is how the bar once came to read 1500%.
|
||||
|
||||
# An endpoint that answers with something else must degrade, not crash.
|
||||
cat >"$stub/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"error":{"message":"nope"}}'
|
||||
# Whole percents: jq prints 1 and 1.0 for the same number, and a contract that
|
||||
# fails on the spelling of a float teaches nothing.
|
||||
percent_for() { jq -r --arg label "$1" '.limits[] | select(.label == $label) | .percent * 100 | round' <<<"$record"; }
|
||||
|
||||
[[ "$(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
|
||||
chmod +x "$stub/curl"
|
||||
run
|
||||
[[ "$(jq -r .status "$output")" == "unavailable" ]] \
|
||||
|| note 'an error response was not reported as unavailable'
|
||||
chmod +x "$stub/codex"
|
||||
|
||||
# ── 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" \
|
||||
|| 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 -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'
|
||||
grep -q 'showAgentUsage' "$aliases" \
|
||||
|| note 'Settings.qml does not alias showAgentUsage, so the binding reads undefined'
|
||||
for key in showAgentUsage agentUsageClaude agentUsageCodex agentUsageRefreshMinutes; do
|
||||
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()
|
||||
match = re.search(r"interval:\s*(\d+)\s*\*\s*60\s*\*\s*1000", text)
|
||||
if not match or int(match.group(1)) < 1:
|
||||
if not re.search(r"interval:\s*root\.refreshMinutes\s*\*\s*60\s*\*\s*1000", text):
|
||||
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)
|
||||
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
|
||||
printf 'agent usage contract: %d finding(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.
|
||||
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
|
||||
# the udev half of systemd, not an optional tool.
|
||||
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)$'
|
||||
# bootctl and coredumpctl ship in systemd-udev, which every Fedora install
|
||||
# carries -- it is the udev half of systemd, not an optional tool. Declaring
|
||||
# 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
|
||||
# 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
|
||||
}' <<<"$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 banner duration followed it and not only the colour.
|
||||
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
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
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 sys
|
||||
|
||||
expected = [
|
||||
"about", "updates", "services", "storage",
|
||||
"about", "updates", "services", "agents", "storage",
|
||||
"snapshots", "containers", "datetime", "sync",
|
||||
]
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
|
||||
@@ -40,6 +40,12 @@ expected = {
|
||||
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
||||
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
||||
"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
|
||||
# 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
|
||||
|
||||
@@ -19,7 +19,9 @@ fail() {
|
||||
# convention the scaffold carries -- the scroll behaviour, the header, the
|
||||
# padding -- was hand-rolled there and quietly different from the other
|
||||
# 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
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -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
|
||||
# starts after a crash does not open with a notification about something
|
||||
# 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
|
||||
|
||||
@@ -42,17 +59,19 @@ calls="$work/calls"
|
||||
stub="$work/bin"
|
||||
mkdir -p "$stub"
|
||||
|
||||
# Two crashes of one program, one of another, and one belonging to somebody
|
||||
# else. journalctl is replaced by a stub that emits them and exits, so the
|
||||
# watcher's follow loop terminates instead of hanging the test.
|
||||
# Two crashes of one program, one of another, one belonging to somebody else,
|
||||
# and one from the ladder's own machinery. journalctl is replaced by a stub that
|
||||
# emits them and exits, so the watcher's follow loop terminates instead of
|
||||
# hanging the test.
|
||||
uid="$(id -u)"
|
||||
cat >"$stub/journalctl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
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_UID":"$uid","COREDUMP_EXE":"/usr/bin/other-program","COREDUMP_COMM":"other-program"}' \\
|
||||
'{"COREDUMP_UID":"99999","COREDUMP_EXE":"/usr/bin/someone-elses","COREDUMP_COMM":"someone-elses"}'
|
||||
'{"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_PID":"4243","COREDUMP_SIGNAL_NAME":"SIGSEGV"}' \\
|
||||
'{"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_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
|
||||
chmod +x "$stub/journalctl"
|
||||
|
||||
@@ -69,8 +88,22 @@ exit 0
|
||||
STUB
|
||||
chmod +x "$stub/busctl"
|
||||
|
||||
: >"$calls"
|
||||
PATH="$stub:$PATH" timeout 20 "$watcher" >/dev/null 2>&1
|
||||
# Fabricated settings rather than this machine's, so the contract's answer does
|
||||
# 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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -92,6 +125,58 @@ grep -q 'someone-elses' "$calls" \
|
||||
grep -q 'panama-test-cra ' "$calls" \
|
||||
&& 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 ───────────────────────────────────────────────────
|
||||
|
||||
grep -q 'org.freedesktop.Notifications' "$watcher" \
|
||||
|
||||
Reference in New Issue
Block a user