#!/bin/bash # omarchy:summary=Watch for process crashes and offer an AI diagnosis # omarchy:hidden=true # systemd-coredump journals every core dump under a known MESSAGE_ID with # structured COREDUMP_* fields, which carry more than the core filenames do. set -uo pipefail # See systemd.journal-fields(7). readonly COREDUMP_MESSAGE_ID=fc2e22bc6ee647b6b90729ab34a250b1 # nf-md-robot_dead, escaped so this file reads without a Nerd Font. readonly CRASH_GLYPH=$'\U000f16a1' # Crash loops dump core repeatedly, so announce each program at most once a # window. readonly dedupe_seconds=${OMARCHY_CRASH_DEDUPE_SECONDS:-60} # Extended regex of process names never worth announcing. readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-} declare -A last_notified announce() { local comm=$1 pid=$2 exe=$3 signal=$4 # The shell owns org.freedesktop.Notifications, so a shell crash takes the # notification server down with it and a toast sent into that gap is lost. # Wait for the restarted shell to claim the name again: the crash least # likely to be delivered is the one most worth reporting. omarchy-notification-wait || return 1 # --exec rather than a libnotify action: the shell runs clicks from its own # hint and never emits ActionInvoked. Keeps the default "omarchy-action" app # name, the only one shouldBypassDnd() lets through. Crash details ride as # discrete argv words, so a hostile process name can't be reparsed as a # command. --exec consumes the rest of the line, so it comes last. omarchy-notification-send \ --urgency critical \ --glyph "$CRASH_GLYPH" \ "Process crashed: $comm" \ "Click to diagnose with AI" \ --exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal" } # -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 '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 # The toast only offers a diagnosis, so it has nothing to offer until an # agent is chosen. Checked per crash, not at startup, so picking one takes # effect without restarting this service. [[ -n $(omarchy-default-agent) ]] || continue # Only this user's crashes; a daemon dumping core is a sysadmin's problem. [[ $uid =~ ^[0-9]+$ ]] || continue ((uid == UID)) || continue # comm is truncated to 15 characters, so prefer the executable's basename. 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 # Only a delivered toast starts the dedupe window. A failed send that # counted would suppress the rest of a crash loop for a minute, and # `journalctl -n 0` never replays what was missed. announce "$name" "$pid" "$exe" "$signal" && last_notified[$name]=$now done