diff --git a/bin/omarchy b/bin/omarchy index 111d214a..4219109b 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -40,6 +40,7 @@ GROUP_DESCRIPTIONS[channel]="Omarchy release channel management" GROUP_DESCRIPTIONS[clipboard]="Clipboard helpers" GROUP_DESCRIPTIONS[cmd]="Command and shortcut helpers" GROUP_DESCRIPTIONS[config]="System configuration helpers" +GROUP_DESCRIPTIONS[crash]="Crash notification controls" GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" diff --git a/bin/omarchy-crash-mute b/bin/omarchy-crash-mute new file mode 100755 index 00000000..88229fea --- /dev/null +++ b/bin/omarchy-crash-mute @@ -0,0 +1,73 @@ +#!/bin/bash + +# omarchy:summary=Silence crash notifications for one program, or list what is silenced +# omarchy:args=[--] [] [on|off|toggle] +# omarchy:examples=omarchy crash mute | omarchy crash mute hyprland | omarchy crash mute /usr/bin/hyprland | omarchy crash mute hyprland off + +# The flag omarchy-crash-watch reads before announcing a crash. Muting is per +# program; Trigger > Toggle > Crash Capture is the switch for all of them. + +set -uo pipefail + +readonly MUTES="$HOME/.local/state/omarchy/toggles/crash-ignore" + +usage() { + echo "Usage: omarchy crash mute [--] [] [on|off|toggle]" >&2 +} + +# Only regular files, because that is all the watcher honours: anything else in +# there would be reported as muted while the crashes kept arriving. The dotted +# glob is for a program legitimately called .hidden, and `.` and `..` fail the +# same -f test that keeps them out. +list() { + local entry found=0 + + for entry in "$MUTES"/* "$MUTES"/.*; do + [[ -f $entry ]] || continue + printf '%s\n' "${entry##*/}" + found=1 + done + + ((found)) || echo "No programs muted. Crashes all notify." +} + +# A program may be named -h, and the router answers that with its own help +# before this ever runs. `omarchy crash mute -- -h` is the way through. +[[ ${1:-} == "--" ]] && shift + +if (($# == 0)); then + list + exit 0 +fi + +program=$1 +action=${2:-on} + +# The watcher keys the mute on the executable's basename, so accept the path it +# reports as readily as the name, and reduce either the same way it does. +program=${program##*/} + +if [[ -z $program || $program == "." || $program == ".." ]]; then + echo "Not a program name: $1" >&2 + usage + exit 1 +fi + +case "$action" in + on|off|toggle) ;; + *) + echo "Not an action: $action" >&2 + usage + exit 1 + ;; +esac + +omarchy-toggle "crash-ignore/$program" "$action" || exit 1 + +# Report what is now true rather than what was asked for: the flag is what the +# watcher reads, and a toggle does not say which way it went. +if omarchy-toggle-enabled "crash-ignore/$program"; then + echo "Muted crash notifications for $program." +else + echo "Crash notifications for $program are back on." +fi diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..d3e78d16 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -48,12 +48,17 @@ announce() { # -n 0 so a restart does not re-announce crashes already dealt with. journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | while IFS= read -r entry; do + # A dash for a field that is empty as well as one that is missing: tab is + # IFS whitespace, so an empty field collapses into the next delimiter and + # every field after it shifts along one. A process can set its own comm to + # nothing, and that crash used to be read as somebody else's and dropped. IFS=$'\t' read -r uid comm pid exe signal < <( - jq -r '[(._UID // "-"), - (.COREDUMP_COMM // "-"), - (.COREDUMP_PID // "-"), - (.COREDUMP_EXE // "-"), - (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + jq -r 'def field: if . == null or . == "" then "-" else . end; + [(._UID | field), + (.COREDUMP_COMM | field), + (.COREDUMP_PID | field), + (.COREDUMP_EXE | field), + (.COREDUMP_SIGNAL_NAME | field)] | @tsv' <<<"$entry" 2>/dev/null ) [[ $pid =~ ^[0-9]+$ ]] || continue @@ -71,11 +76,29 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast; a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match; and a dash is what the read above puts there when + # the crash recorded no name at all. + [[ -n $name && $name != "-" && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so omarchy-crash-mute can + # lift one without reading, rewriting and re-parsing the rest. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..ea773007 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,38 @@ 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, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash you have explained often keeps happening anyway. Finish by offering to +silence notifications for **that one program**, and never run it unprompted. Say +how to lift it in the same breath, so it is not a one-way door. + +```bash +omarchy-crash-mute '' # silence it +omarchy-crash-mute '' off # let it speak again +omarchy-crash-mute # list what is muted +``` + +Pass the `binary:` path from the crash facts, or the `process:` name where no +binary was recorded; the command reduces either to the name the watcher keys on. +A diagnosis run by hand from `omarchy agent crash ` has neither, so take +them from `coredumpctl info`. Prefer the binary: a process name is truncated to +15 characters and a basename is not, so muting the truncated form matches +nothing, forever, while looking like it worked. + +Quote it. The name is whatever the crashed program's author called a file, and a +single quote inside one closes yours and runs the rest as your shell. + +The key is a bare name, so anything run through an interpreter is keyed as the +interpreter: muting `python3.13` silences every Python program on the machine. +Say so rather than quietly doing it. + +None of this fixes anything, and a mute offered in place of a fix that was within +reach is the wrong answer. For every program rather than one, the switch is +_Trigger > Toggle > Crash Capture_. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..57698f42 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,6 +39,8 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end. `omarchy crash mute hyprland` stops the notifications for that program only, `omarchy crash mute hyprland off` brings them back, and `omarchy crash mute` on its own lists what you've muted. It takes the binary's path as happily as its name, so `omarchy crash mute /usr/bin/hyprland` does the same thing. Quote a name with a space in it, as in `omarchy crash mute 'Some App'`. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### Desktop apps The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..1b7e93d0 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,334 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user- fail "crash capture is no longer on by default for new installs" pass "crash capture is on by default" +require_command jq + +# The per-program mute, driven through the real watcher with a stubbed journal: +# these prove what a person sees -- a toast arriving or not -- where asserting +# that a flag file was read would prove only that a flag file was read. +watch_bin="$TMPDIR/watch-bin" +watch_home="$TMPDIR/watch-home" +NOTIFY_LOG="$TMPDIR/notify-log" +JOURNAL_ENTRIES="$TMPDIR/journal-entries" + +mkdir -p "$watch_bin" "$watch_home" + +cat >"$watch_bin/journalctl" <<'SH' +#!/bin/bash +cat "$JOURNAL_ENTRIES" +SH + +cat >"$watch_bin/omarchy-default-agent" <<'SH' +#!/bin/bash +echo claude +SH + +cat >"$watch_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$watch_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_LOG" +SH + +chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \ + "$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send" + +reset_entries() { + : >"$JOURNAL_ENTRIES" +} + +# One core dump as systemd-coredump journals it. The UID must be this user's, or +# the watcher discards it as somebody else's crash before anything under test. +crash_entry() { + local comm="$1" exe="$2" + + jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \ + '{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242", + COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES" +} + +# The stubbed journalctl ends after the entries, so the watcher's loop ends too. +# Its exit status is asserted rather than discarded: a watcher that dies on a +# muted crash notifies about nothing afterwards, which every assertion below +# that expects silence would otherwise read as success. +run_watch() { + local status=0 + + : >"$NOTIFY_LOG" + + PATH="$watch_bin:$ROOT/bin:$PATH" \ + JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \ + NOTIFY_LOG="$NOTIFY_LOG" \ + HOME="$watch_home" \ + "$ROOT/bin/omarchy-crash-watch" || status=$? + + (( status == 0 )) || + fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" +} + +# Through the real command rather than writing the flag by hand: these assertions +# are then the guard that the thing the diagnosis runs and the thing the watcher +# reads have not drifted apart. +mute() { + HOME="$watch_home" PATH="$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null +} + +announced() { + grep -Fq "Process crashed: $1" "$NOTIFY_LOG" +} + +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "a crash nobody muted still announces itself" +pass "a crash nobody muted still announces itself" + +mute hyprland on +run_watch +! announced hyprland || + fail "muting a program stops the crash notifications the diagnosis offered to stop" +pass "muting a program stops its crash notifications" + +reset_entries +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "muting one program silences every other program, which is the global toggle's job and not this one's" +pass "muting one program leaves every other program announcing" + +mute hyprland off +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "un-muting a program brings its crash notifications back" +pass "un-muting a program brings its crash notifications back" + +# The diagnosis tells the user to mute the name the toast showed them, so the +# toast has to show the name the watcher checks. COMM is truncated to 15 +# characters and the executable's basename is not, and announcing the truncated +# one would leave a dutifully-followed mute matching nothing forever. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +run_watch +announced chromium-browser || + fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing" +pass "the toast announces the name the mute is keyed on" + +mute chromium-browser on +run_watch +! announced chromium-browser || + fail "the mute is keyed on the name the notification announced, not on the truncated COMM" +pass "muting the announced name silences a program whose COMM was truncated" + +# A muted crash must not end the watcher. Restart=always would paper over it +# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which +# never replays the crashes it missed while it was away. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "a muted crash stops the watcher reading the journal, losing every crash after it" +pass "a muted crash does not stop the watcher reading the next one" + +# A process can set its own comm to anything prctl takes, slashes included, and +# a crash with no recorded executable falls back to it. A name that climbed out +# of crash-ignore/ would let a crashing program silence itself against an +# unrelated flag -- and have the diagnosis write one there on the user's behalf. +# The fixture carries two slashes so that dropping only the first is not mistaken +# for dropping all of them. +reset_entries +crash_entry a/../bar-off - +sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off" +touch "$sibling_flag" +run_watch +announced bar-off || + fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification" +pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle" +rm -f "$sibling_flag" + +# Stripping to the last component does not always leave a component. An empty +# name is no kind of array subscript and no kind of toast, and a dot component +# names a directory the mute would touch and then never match. +for empty_comm in / a/ . ..; do + reset_entries + crash_entry "$empty_comm" - + run_watch + announced unknown || + fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on" +done +pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" + +# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field +# collapses and every field after it shifts along one -- the pid becomes a path, +# the crash reads as somebody else's, and it is dropped without a word. +reset_entries +crash_entry "" - +crash_entry nautilus /usr/bin/nautilus +run_watch +announced unknown || + fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it" +announced nautilus || + fail "an empty comm derails the rest of the journal entry" +pass "an empty comm is announced rather than parsed into the next field" + +# Only "." and ".." are special. A leading dot is an ordinary filename, and +# folding those into the fallback would have one program's mute silence another. +for dotted_comm in .hidden ...; do + reset_entries + crash_entry "$dotted_comm" - + run_watch + announced "$dotted_comm" || + fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes" +done +pass "a leading dot is an ordinary name rather than a special component" + +# And the name it settles on is mutable like any other. +mute unknown on +reset_entries +crash_entry / - +run_watch +! announced unknown || + fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced" +pass "the fallback name can be muted like any other" +mute unknown off + +# What omarchy-crash-mute does on its own. That it agrees with the watcher is +# already covered above, which drives it for every mute it makes. +mute_home="$TMPDIR/mute-home" +mkdir -p "$mute_home" + +crash_mute() { + HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@" +} + +mute_flag() { + [[ $1 == "--" ]] && shift + printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1" +} + +crash_mute | grep -Fq "No programs muted" || + fail "an empty mute list prints nothing, so a user cannot tell it from a broken command" +pass "the command says so when nothing is muted" + +crash_mute hyprland >/dev/null +crash_mute | grep -Fqx hyprland || + fail "a muted program is missing from the list, so a mute cannot be found again to lift it" +pass "the command lists what it muted" + +# The watcher keys on the basename, so the command has to take the path a crash +# recorded and land on the same flag the watcher will look for. +crash_mute /usr/lib/chromium/chromium-browser >/dev/null +[[ -f $(mute_flag chromium-browser) ]] || + fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag" +pass "the command reduces a path to the name the watcher checks" + +crash_mute hyprland off >/dev/null +[[ ! -f $(mute_flag hyprland) ]] || + fail "off leaves the program muted, making the mute a one-way door" +pass "the command un-mutes" + +# Muting is not flipping. The diagnosis offers this on a program the user may +# already have muted, and asking for a mute twice has to leave it muted. +crash_mute hyprland >/dev/null +crash_mute hyprland >/dev/null +[[ -f $(mute_flag hyprland) ]] || + fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on" +pass "asking to mute twice leaves it muted" + +# A program may legitimately be called .hidden, and a mute nobody can see is a +# mute nobody can lift. +crash_mute .hidden >/dev/null +crash_mute | grep -Fqx .hidden || + fail "a mute on a dotted name is missing from the list, so it can never be found and lifted" +pass "the list shows a name that begins with a dot" + +# It turns what it is given into a path, so it has to refuse whatever is not one +# component of one. +for bad_name in . .. /; do + ! crash_mute "$bad_name" >/dev/null 2>&1 || + fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read" +done +pass "the command refuses a name that is not a name" + +! crash_mute hyprland sideways >/dev/null 2>&1 || + fail "an action it does not know is treated as a mute, so a typo silences a program" +pass "the command refuses an action it does not know" + +# And says what it refused, or the user retypes the same thing. Captured rather +# than piped: the command exits non-zero here, which pipefail would surface as +# the pipeline's status and read as a failed assertion. +refusal=$(crash_mute hyprland sideways 2>&1) || true +grep -Fq "Not an action" <<<"$refusal" || + fail "an unknown action is refused without naming it, leaving the user nothing to correct" +pass "the command names the action it refused" + +crash_mute ../bar-off >/dev/null +[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] || + fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead" +pass "the command cannot be talked into writing outside crash-ignore/" + +# A program may be called -h, and the router answers that with its own help +# before the command runs. A leading -- is the way through, so it has to be +# consumed rather than taken for the program name. +crash_mute -- -h >/dev/null 2>&1 || + fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all" +[[ -f $(mute_flag -- -h) ]] || + fail "a leading -- is taken for the program name, so muting -h mutes something else" +pass "a leading -- lets a program named like a flag be muted" + +# toggle is advertised, so it has to flip both ways rather than quietly mute. +crash_mute toggler off >/dev/null +crash_mute toggler toggle >/dev/null +[[ -f $(mute_flag toggler) ]] || + fail "toggle does not mute an un-muted program" +crash_mute toggler toggle >/dev/null +[[ ! -f $(mute_flag toggler) ]] || + fail "toggle mutes but never un-mutes, so the advertised action only goes one way" +pass "toggle flips a mute both ways" + +# The listing means what the watcher means, and the watcher honours a regular +# file. Anything else in there is not a mute, however much it looks like one. +mkdir -p "$(mute_flag notactuallymuted)" +! crash_mute | grep -Fqx notactuallymuted || + fail "a directory is reported as muted while that program's crashes keep arriving" +pass "the listing counts only the flags the watcher honours" +rmdir "$(mute_flag notactuallymuted)" + +# A mute that could not be written must not be reported as one. Without this the +# command can print success for a flag that was never created. +failing_bin="$TMPDIR/failing-bin" +mkdir -p "$failing_bin" +cat >"$failing_bin/omarchy-toggle" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$failing_bin/omarchy-toggle" + +status=0 +refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$? +(( status != 0 )) || + fail "a mute that could not be written exits zero, so nothing downstream learns it failed" +! grep -Fq "Muted crash notifications" <<<"$refusal" || + fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not" +pass "a mute that could not be written is not reported as one" + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'omarchy-crash-mute' "$skill" || + fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out" +pass "the diagnosis names the command that mutes" + +grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" || + fail "the crash group has no description, so the router lists a group it cannot describe" +pass "the crash group is described in the router" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')