Merge pull request #8549 from basecamp/crash-notification-mute

Let a crash diagnosis mute that program's notifications
This commit is contained in:
David Heinemeier Hansson
2026-08-27 13:19:46 +02:00
committed by GitHub
6 changed files with 464 additions and 6 deletions
+1
View File
@@ -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"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# omarchy:summary=Silence crash notifications for one program, or list what is silenced
# omarchy:args=[--] [<program>] [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 [--] [<program>] [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
+28 -5
View File
@@ -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
+32 -1
View File
@@ -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 '<program>' # silence it
omarchy-crash-mute '<program>' 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 <pid>` 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
+2
View File
@@ -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 <pid>` 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.
+328
View File
@@ -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')