From 8d14869689ae4772b1181d1012a37c7b0712cfc9 Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Thu, 27 Aug 2026 10:28:11 +0200 Subject: [PATCH] Let a crash diagnosis mute that program's notifications A crash that is understood is not a crash that stops: an upstream bug waiting on a release, a program that dumps core every time it exits. The diagnosis explains it once and the toast keeps arriving, and the only answer Omarchy had was Crash Capture, which turns off every program's notifications in order to silence one. The watcher already resolves a name to dedupe on and announces that same name in the toast, so the mute is keyed on it: a flag file under toggles/crash-ignore/, written by the existing omarchy-toggle and read by the existing omarchy-toggle-enabled. One flag per name rather than one list, so `on` mutes, `off` un-mutes, and `ls -A` shows what is muted, with no new file format and nothing to parse. It is the executable's basename wherever one was recorded, falling back to the process name, which the kernel truncates to fifteen characters -- muting the truncated form would match nothing, forever, while looking like it worked. The name is not always a name, though, and the mute turns it into a path. A program picks its own comm and prctl takes anything, including slashes, and the watcher falls back to comm whenever a crash carries no absolute executable. So it is stripped to its last component first: without that, `a/../bar-off` is a legal comm aimed at an unrelated Omarchy flag, letting a crashing program suppress its own notification and letting a user who accepted the offered mute hide their bar instead. Stripping does not always leave a component either -- `/` leaves an empty string, which is no kind of array subscript and no kind of toast, and `.` or `..` names a directory that omarchy-toggle would touch and report success on, leaving a mute that never matches. Both fall back to `unknown`, the word omarchy-agent-crash already uses for a name it does not have, and which mutes like any other. The skill offers this at the end of a diagnosis and never runs it unprompted, which makes it the single change a diagnosis may make to a system it otherwise only reads. It tells the agent to use the name it was handed rather than re-derive one, since the watcher resolved that name already and the two agree for ordinary names and not for strange ones; a diagnosis started by hand from `omarchy agent crash ` is given no name and gets the derivation instead. It also says to treat the name as hostile text rather than as a word -- it is whatever the crashed program's author called a file, so a single quote inside one closes the quotes around it and the rest runs as the shell -- and to check the flag arrived rather than assume it. Co-Authored-By: Codex XHigh --- bin/omarchy-crash-watch | 17 ++ default/agents/skills/diagnose-crash/SKILL.md | 55 ++++- manual/17-ai.md | 2 + test/shell.d/crash-capture-test.sh | 188 ++++++++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..09dd32d3 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -71,11 +71,28 @@ 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, and a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match. + [[ -n $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 it un-mutes with + # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + 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..7faadb1f 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,60 @@ 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 that is now understood keeps announcing itself, and understanding it +rarely stops it happening: an upstream bug waiting on a release, a program that +dumps core every time it exits, a driver that misbehaves on this hardware. Finish +by offering to silence crash notifications for **that one program**: + +```bash +omarchy-toggle 'crash-ignore/' on +``` + +`` is the `process:` name in the crash facts, verbatim. The watcher works +that name out and then announces it, so what you were handed is already the exact +string the mute is keyed on — do not re-derive it from `coredumpctl` when you were +given it, because the two agree for ordinary names and not for strange ones. + +A diagnosis started by hand from `omarchy agent crash ` is given no name, so +there you do have to work it out the way the watcher does: the executable's +basename when an absolute `Executable:` was recorded, otherwise the process name +with everything up to the last `/` dropped, and `unknown` when that leaves +nothing, `.` or `..`. Prefer the executable — the kernel truncates the process +name to 15 characters and does not truncate the basename, so a mute on the +truncated one matches nothing, forever, while looking like it worked. + +The name is whatever the crashed program's author chose to call a file, so handle +it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, +but a name containing a single quote closes them and the rest of it runs as your +shell — escape it, or the program that just crashed chooses the command. Then +check the flag actually arrived, which is also how you learn a name was too long +for the filesystem to keep: + +```bash +omarchy-toggle-enabled 'crash-ignore/' && echo muted +``` + +Offer it; never run it unprompted. The user may well want to keep being told. + +Say how to undo it in the same breath, so it is not a one-way door: the same +command with `off` un-mutes, and each mute is one file in +`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the +directory appears with the first mute, so before that there is nothing to list. + +The key is a bare name, so programs sharing one share a mute, and anything run +through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` +silences every other Python or Node program on the machine, which is rarely what +the user means: say so rather than quietly doing it. + +This silences one program. Every other crash still notifies, and the muted +program still crashes — nothing here fixes anything, and a mute offered instead +of a fix that was within reach is the wrong answer. If the user wants crash +notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..5588ef9b 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 toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. 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..2f705310 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,194 @@ 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" +} + +mute() { + HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" +} + +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" + +# 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 + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'crash-ignore/' "$skill" || + fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" +grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || + fail "the watcher no longer reads the flag the diagnosis offers to write" +pass "the diagnosis and the watcher name the same flag" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')