diff --git a/crashes/README.md b/crashes/README.md new file mode 100644 index 00000000..e9f862cf --- /dev/null +++ b/crashes/README.md @@ -0,0 +1,116 @@ +# Crashes + +Crash history for your desktop, read from systemd-coredump, with one-click AI +diagnosis. When one of *your* processes segfaults or dumps core you get a +critical notification, and the panel keeps the list around afterwards — so a +crash you missed while watching a video is still there when you come back to +it. A second tab shows the current boot's journal errors by severity. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `umedbazarov/crashes` | +| Entries | Panel: `panel`; service: `service` | + +## Requirements + +- `coredumpctl` (systemd-coredump) active and collecting — the default on + Arch and most systemd distributions; verify with `coredumpctl list`. +- `jq`, required — filters the coredump list to your own crashes outside the + plugin runtime. +- `notify-send` (libnotify), required for the crash notifications. +- `bash`, required — runs the diagnosis command. +- A terminal AI coding agent for the diagnosis buttons (Claude Code, Codex, + opencode, grok, …). Without one the panel still works as a crash and error + browser; the diagnosis button then explains what to install. + + Whichever of those are installed are tried in turn: before the window + opens the agent is asked a throwaway question, and one that cannot answer + — out of quota, logged out, offline — is skipped in favour of the next. + You see which was chosen in the terminal itself. Turn that check off with + **Check the agent can answer** if you would rather not spend the request, + and set the order with **Agent order**. A configured **Agent command** is + used on its own: its headless syntax is unknown, so it cannot be tested + the same way. +- `gdb` and your distribution's debuginfod are optional, but the guide the + agent follows uses them to symbolize a backtrace when they are available. + +## Usage + +The plugin has no bar widget. Open the panel from the plugin's row in +Settings, or bind it in your compositor: + +```sh +noctalia msg panel-toggle umedbazarov/crashes:panel +``` + +**Crashes** lists the coredumps of your own processes, newest first: program, +signal, time, PID, and whether the core file still exists (rotated-away dumps +are marked and cannot be analyzed). Each row has two buttons: + +- **Diagnose** (stethoscope) opens your agent in a terminal with the recorded + facts and `diagnose.md`, an evidence-first investigation guide: read + `coredumpctl info`, rule out OOM kills, correlate the timestamp with + filesystem mtimes, the journal and recent package updates, symbolize the + backtrace with gdb + debuginfod, and report honestly — including what the + evidence does *not* show. The guide explicitly tells the agent to leave the + system as it found it. +- **Mute** (bell) stops notifications for that one program; press it again to + unmute. Mutes are kept in the plugin's data directory. + +**Errors** shows this boot's journal entries at a chosen severity — `err` and +stricter by default, up to `emerg` only — with the same diagnosis button per +entry, which asks the agent to pull the surrounding journal context and +explain what the message means and how serious it is. + +Only your own crashes are listed (matched by uid): a system daemon dumping +core is a sysadmin's problem, not a desktop notification. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `poll_seconds` | `int` | `20` | How often the coredump list is re-read. | +| `notify` | `bool` | `true` | Announce new crashes with a critical notification. Turn off if something else already announces them. | +| `agent_cmd` | `string` | *(empty)* | Command that receives the prompt as its last argument, e.g. `claude`, `codex`, `opencode --prompt`. Empty: pick from the known agents (see below). | +| `agent_order` | `string` | *(empty)* | Which known agents to try and in what order, e.g. `codex, claude`. Empty: claude, opencode, codex, grok. | +| `probe_agent` | `bool` | `true` | Check that the agent can answer before opening it, and move on to the next one if it cannot. | +| `extra_prompt` | `string` | *(empty)* | Text appended to every prompt — e.g. "Answer in German", or house rules for the agent. | +| `terminal_cmd` | `string` | `kitty -e` | Wrapper that opens the agent in a window. Leave empty if your agent command opens its own window. | +| `env_file` | `string` | *(empty)* | Sourced before the agent starts. For a proxy or an API key your shell profile sets but a panel click does not inherit. | + +## IPC + +```sh +noctalia msg plugin umedbazarov/crashes:service all refresh +``` + +Re-reads the coredump list immediately instead of waiting for the next poll. + +## Notes + +- **Commands spawned.** `coredumpctl list --json=short` piped through `jq` + (the crash list), `id -u` once at startup (only your uid's crashes are + shown), `journalctl -b -p -o json` piped through `jq` for the + Errors tab — and only when that tab is open, `notify-send` for + notifications, and `setsid bash -c …` to launch the agent in a terminal + when you press a diagnosis button. Nothing runs an agent on its own. +- **Files written.** Only in the plugin's data directory: `seen.json` (the + newest crash already announced, so a restart does not re-announce old + ones) and `mutes.json` (the per-program mute list). +- **No privileges.** Everything runs as your user; the plugin never modifies + system configuration and never touches the dumps themselves. +- **What is sent to the agent.** The crash facts shown in the row (program, + PID, binary path, signal, time) or the journal line you clicked, plus the + path to the bundled guide. Whatever the agent then reads is up to it and + your agent's own permissions — the guide keeps it to read-only + investigation, and warns that a core dump is a verbatim copy of process + memory that may contain secrets. +- **Budget-friendly.** Filtering happens in `coredumpctl`/`jq` outside the + Luau runtime, at most 30 rows are parsed per poll, and the Errors tab is + only queried on request. + +## License + +MIT. diff --git a/crashes/diagnose.md b/crashes/diagnose.md new file mode 100644 index 00000000..3b1109f3 --- /dev/null +++ b/crashes/diagnose.md @@ -0,0 +1,86 @@ +# Diagnosing a Crash + +Work from evidence. The goal is an honest account of what happened, not a +plausible-sounding story. + +## Establish the facts + +`coredumpctl info ` is the starting point. Beyond the backtrace, note the +**command line** the process was started with — it usually reveals what the +program was working on when it died, which is often the whole answer. + +`coredumpctl list` shows whether this crash is a one-off or a pattern. Repeated +crashes of the same program, or several programs dying together, point somewhere +different than a single failure does. + +## Rule out the boring causes first + +Check resource exhaustion before blaming the program: `free -h`, and the journal +for OOM kills. A process killed by the OOM killer is not a bug in that process. + +## Correlate against the timeline + +The crash timestamp is the most underused piece of evidence. Compare it against: + +- **Filesystem mtimes.** A directory or file whose mtime lands on the same second + as the crash strongly suggests what triggered it. +- **The journal** around that moment, for related warnings from the same or + neighbouring processes. +- **Recent package updates.** A crash that starts right after an update points at + the update (on Arch: `/var/log/pacman.log`). + +## Read the whole core, not just frame 0 + +Thread stacks other than the crashing one show what work was **in flight** — +thumbnailers, image loaders, IPC readers, GPU queues. That context often explains +the trigger even when the crashing frame itself cannot be symbolized. + +Note any third-party code in the address space: file-manager or browser +extensions, plugins, out-of-tree drivers. In-process third-party code is a common +crash source and worth flagging — but do not pin blame on it without evidence +that it is actually implicated. + +## Symbolize when you can + +Most distributions run a debuginfod server (Arch: +`https://debuginfod.archlinux.org`, others: see `debuginfod.` docs): + +```bash +core=$(mktemp -t crash-XXXXXX.core) +trap 'rm -f "$core"' EXIT +coredumpctl dump --output="$core" +DEBUGINFOD_URLS="https://debuginfod.archlinux.org" \ + gdb -q "$core" \ + -batch -ex 'set debuginfod enabled on' -ex 'bt' +``` + +A core is a verbatim copy of the process's memory and can hold passwords, tokens, +and private documents. Write it to a fresh `mktemp` path rather than a predictable +shared one, and delete it when you are done — never leave it lying in `/tmp`. + +Many packages publish no debug symbols. When frames stay unresolved, say so — +never invent function names to fill the gap. An unsymbolized stack still has +shape: which library each frame belongs to, and whether the crash came from a +signal handler, a main loop, or a worker thread. + +## Report + +1. What crashed, and what it was doing at the time. +2. The most likely mechanism — separating clearly what the evidence **proves** + from what you are **inferring**. +3. Whether any user data was lost, and where it can be recovered from. Check the + trash before concluding anything is gone. +4. Whether it is likely to recur, and what would avoid or fix it. + +Be straight about the limits of the evidence. If the cause is genuinely +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. + +## Afterwards + +If this program keeps crashing and the user has heard enough about it, they can +mute its notifications from the Crashes panel (the bell button on its row) — +mention that, but never mute anything yourself. diff --git a/crashes/panel.luau b/crashes/panel.luau new file mode 100644 index 00000000..391c136b --- /dev/null +++ b/crashes/panel.luau @@ -0,0 +1,395 @@ +--!nonstrict +-- Crashes panel: recent coredumps of this user, newest first. Per row: +-- one-click AI diagnosis in a terminal and a per-program mute toggle. +-- Pure renderer over "crashes_state"; mutes and refresh go through +-- "crashes_request" so the service stays the single writer of its files. +-- Open via IPC: noctalia msg panel-toggle umedbazarov/crashes:panel + +local STATE_KEY = "crashes_state" +local REQUEST_KEY = "crashes_request" + +local snapshot = nil +local launching = {} -- pid -> true while a diagnosis terminal is being spawned + +local view = "crashes" -- crashes|journal +-- Level selector index -> journald priority (see syslog(3)). +local LEVELS = { 3, 2, 1, 0 } +local levelIndex = 0 -- 0-based, like ui.select's selectedIndex + +local render + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function cfg(key, fallback) + -- getConfig returns nil until the setting is materialized in + -- settings.toml; the manifest default is NOT substituted by the runtime. + -- An empty string is a real value (an intentionally empty terminal_cmd). + local value = noctalia.getConfig(key) + if value == nil then + return fallback + end + return value +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function request(action, name, level) + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + noctalia.state.set(REQUEST_KEY, { nonce = nonce, action = action, name = name, level = level }) +end + +-- The level selector's onChange receives (index, label). +function onLevel(index, _label) + levelIndex = tonumber(index) or 0 + request("journal", nil, LEVELS[levelIndex + 1]) +end + +local function crashList() + if snapshot == nil or type(snapshot.crashes) ~= "table" then + return {} + end + return snapshot.crashes +end + +-- ── Diagnosis ─────────────────────────────────────────────────────────────── + +-- The agents this plugin knows how to start, and how to ask each one a +-- throwaway question. `probe` is what tells an agent that is out of quota or +-- logged out from one that is ready: the answer is thrown away, only whether +-- it came matters. grok has no documented headless mode, so it is only +-- checked for existence. +local AGENTS = { + { name = "claude", bin = "claude", probe = "claude -p ok", run = 'claude "$0"' }, + { name = "opencode", bin = "opencode", probe = "opencode run ok", + run = 'opencode --prompt "$0"' }, + { name = "codex", bin = "codex", probe = "codex exec ok", run = 'codex "$0"' }, + { name = "grok", bin = "grok", probe = nil, run = 'grok -- "$0"' }, +} + +local function agentOrder() + local wanted = tostring(cfg("agent_order", "")):gsub("^%s+", ""):gsub("%s+$", "") + if wanted == "" then + return AGENTS + end + -- The names the user listed, in their order; the rest keep their place + -- at the back rather than being dropped. + local ordered, taken = {}, {} + for name in wanted:gmatch("[^,%s]+") do + for _, agent in ipairs(AGENTS) do + if agent.name == name and not taken[name] then + taken[name] = true + table.insert(ordered, agent) + end + end + end + for _, agent in ipairs(AGENTS) do + if not taken[agent.name] then + table.insert(ordered, agent) + end + end + return ordered +end + +-- Runs in the terminal that just opened: pick an agent, then become it. The +-- picking happens there rather than here so the window appears immediately +-- and the user can see which agent was chosen and why. +local function agentChain() + local probe = cfg("probe_agent", true) ~= false + local parts = {} + for _, agent in ipairs(agentOrder()) do + local block = "if command -v " .. agent.bin .. " >/dev/null 2>&1; then\n" + if probe and agent.probe ~= nil then + block = block + .. ' printf "Asking %s...\\n" ' .. agent.name .. "\n" + .. " if " .. agent.probe .. " >/dev/null 2>&1; then exec " .. agent.run .. "; fi\n" + .. ' printf "%s could not answer - trying the next agent.\\n" ' .. agent.name .. "\n" + else + block = block .. " exec " .. agent.run .. "\n" + end + table.insert(parts, block .. "fi\n") + end + table.insert(parts, 'echo "No AI agent could be reached ' + .. '(claude/opencode/codex/grok) - check that one is installed and signed in, ' + .. 'or set one in the plugin settings."\nread -r _\n') + return table.concat(parts) +end + +-- Runs the agent with a prompt in a terminal. terminal_cmd wraps agent_cmd; +-- an empty terminal_cmd means the agent command opens its own window. The +-- prompt rides as one quoted argument. PATH: the shell environment here has +-- neither ~/.local/bin nor the npm bin dir, which is exactly where these +-- agents usually live - without this the click silently does nothing. +local function launchAgent(key, prompt) + if launching[key] == true then + return + end + launching[key] = true + render() + + -- User-supplied text appended to every prompt (answer language, house rules). + local extra = tostring(cfg("extra_prompt", "")) + if extra:gsub("%s", "") ~= "" then + prompt = prompt .. "\n" .. extra .. "\n" + end + + -- No agent_cmd set: the first well-known agent that is installed and can + -- actually answer. $0 is the prompt. + local agent = tostring(cfg("agent_cmd", "")) + if agent:gsub("%s", "") == "" then + agent = "bash -c " .. shellQuote(agentChain()) + end + local terminal = tostring(cfg("terminal_cmd", "kitty -e")) + local wrapped = agent .. " " .. shellQuote(prompt) + if terminal:gsub("%s", "") ~= "" then + wrapped = terminal .. " " .. wrapped + end + -- An env file, if the user named one: agents behind a proxy or reading an + -- API key from the shell profile get neither from a panel click. + local envFile = tostring(cfg("env_file", "")):gsub("^%s+", ""):gsub("%s+$", "") + local sourceEnv = "" + if envFile ~= "" then + local quoted = envFile:sub(1, 2) == "~/" + and ('"$HOME"/' .. shellQuote(envFile:sub(3))) + or shellQuote(envFile) + sourceEnv = ". " .. quoted .. " 2>/dev/null; " + end + + local launch = sourceEnv + .. 'export PATH="$HOME/.local/bin:$HOME/.local/share/npm/bin:$PATH"; ' .. wrapped + + noctalia.runAsync("setsid bash -c " .. shellQuote(launch) .. " >/dev/null 2>&1 &", function() + launching[key] = nil + render() + end) +end + +local function diagnose(entry) + local guide = (noctalia.pluginDir() or ".") .. "/diagnose.md" + local prompt = "A process crashed on this machine and I want to know why.\n\n" + .. "What systemd-coredump recorded:\n" + .. " process: " .. entry.name .. "\n" + .. " PID: " .. entry.pid .. "\n" + .. " binary: " .. entry.exe .. "\n" + .. " signal: " .. entry.sigName .. "\n" + .. " time: " .. entry.timeLabel .. "\n" + .. " core: " .. (entry.core and "present" or "missing") .. "\n\n" + .. "Read the investigation guide first and follow it:\n\n " .. guide .. "\n" + launchAgent(entry.pid, prompt) +end + +local function analyzeJournal(entry, index) + local prompt = "An error appeared in the systemd journal on this machine and I want to understand it.\n\n" + .. "Journal entry:\n" + .. " source: " .. entry.name .. "\n" + .. " priority: " .. entry.prioName .. "\n" + .. " time: " .. (entry.timeIso or entry.timeLabel) .. "\n" + .. " message: " .. entry.msg .. "\n\n" + .. "Investigate from evidence, not guesswork: pull the surrounding journal context\n" + .. "(journalctl -b with SYSLOG_IDENTIFIER/_COMM filters, and --since/--until a few\n" + .. "minutes around that time), check whether it repeats across the boot, and\n" + .. "correlate with related units and recent package updates. Then explain what the\n" + .. "message means, how serious it is, and what — if anything — should be done.\n" + .. "Separate clearly what the evidence proves from what you infer. Leave the\n" + .. "system as you found it: diagnosis reads, it does not fix or reconfigure.\n" + launchAgent("jrn-" .. index, prompt) +end + +-- ── Render ────────────────────────────────────────────────────────────────── + +local function crashRow(entry, index) + local muted = entry.muted == true + local nameColor = muted and "on_surface_variant" or "on_surface" + + return ui.row({ key = "crash-" .. entry.pid .. "-" .. index, gap = 8, align = "center" }, { + ui.glyph({ name = "bug", size = 14, color = muted and "on_surface_variant" or "error" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = entry.name, fontSize = 13, fontWeight = "bold", color = nameColor, maxLines = 1 }), + ui.label({ + text = entry.sigName .. " · " .. entry.timeLabel .. " · PID " .. entry.pid + .. (entry.core and "" or (" · " .. tr("no_core"))), + fontSize = 10, + color = "on_surface_variant", + maxLines = 1, + }), + }), + ui.button({ + key = "diag-" .. entry.pid .. (launching[entry.pid] and "-off" or ""), + glyph = "stethoscope", + variant = "ghost", + enabled = launching[entry.pid] ~= true and entry.core, + tooltip = entry.core and tr("tip_diagnose") or tr("tip_no_core"), + onClick = function() + diagnose(entry) + end, + }), + ui.button({ + key = "mute-" .. entry.name .. "-" .. tostring(muted), + glyph = muted and "bell-off" or "bell", + variant = "ghost", + tooltip = muted and tr("tip_unmute") or tr("tip_mute"), + onClick = function() + request(muted and "unmute" or "mute", entry.name) + end, + }), + }) +end + +local function journalRow(entry, index) + -- 0..2 (emerg/alert/crit) get the error badge, err the plain one. + local badgeColor = entry.prio <= 2 and "error" or "secondary" + return ui.row({ key = "jrn-" .. index, gap = 8, align = "center" }, { + ui.label({ text = entry.prioName, fontSize = 10, fontWeight = "bold", color = badgeColor }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = entry.name .. " · " .. entry.timeLabel, fontSize = 10, color = "on_surface_variant", maxLines = 1 }), + ui.label({ text = entry.msg, fontSize = 11, color = "on_surface", maxLines = 2 }), + }), + ui.button({ + key = "jdiag-" .. index .. (launching["jrn-" .. index] and "-off" or ""), + glyph = "stethoscope", + variant = "ghost", + enabled = launching["jrn-" .. index] ~= true, + tooltip = tr("tip_diagnose"), + onClick = function() + analyzeJournal(entry, index) + end, + }), + }) +end + +local function renderCrashes(children) + local list = crashList() + if #list == 0 then + table.insert(children, ui.spacer({ flexGrow = 1 })) + table.insert(children, ui.label({ text = tr("empty"), color = "on_surface_variant" })) + table.insert(children, ui.label({ text = tr("empty_hint"), fontSize = 11, color = "on_surface_variant", maxLines = 2 })) + table.insert(children, ui.spacer({ flexGrow = 1 })) + else + local rows = {} + for index, entry in ipairs(list) do + if index > 1 then + table.insert(rows, ui.separator({ key = "sep-" .. index })) + end + table.insert(rows, crashRow(entry, index)) + end + table.insert(children, ui.scroll({ key = "crashes", flexGrow = 1, gap = 4 }, rows)) + end +end + +local function renderJournal(children) + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("level_label"), fontSize = 11, color = "on_surface_variant" }), + ui.select({ + selectedIndex = levelIndex, + options = { tr("level_err"), tr("level_crit"), tr("level_alert"), tr("level_emerg") }, + onChange = "onLevel", + flexGrow = 1, + }), + })) + + local entries = {} + if snapshot ~= nil and type(snapshot.journal) == "table" and type(snapshot.journal.entries) == "table" then + entries = snapshot.journal.entries + end + if #entries == 0 then + table.insert(children, ui.spacer({ flexGrow = 1 })) + table.insert(children, ui.label({ text = tr("journal_empty"), color = "on_surface_variant" })) + table.insert(children, ui.spacer({ flexGrow = 1 })) + else + local rows = {} + for index, entry in ipairs(entries) do + if index > 1 then + table.insert(rows, ui.separator({ key = "jsep-" .. index })) + end + table.insert(rows, journalRow(entry, index)) + end + table.insert(children, ui.scroll({ key = "journal", flexGrow = 1, gap = 4 }, rows)) + end +end + +render = function() + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "bug", size = 18, color = "primary" }), + ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ + glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), + onClick = function() + if view == "journal" then + request("journal", nil, LEVELS[levelIndex + 1]) + else + request("refresh") + end + end, + }), + ui.button({ + glyph = "close", variant = "ghost", tooltip = tr("tip_close"), + onClick = function() + panel.close() + end, + }), + }), + -- Tab switch: crashes / journal errors by level. + ui.row({ gap = 6, align = "center" }, { + ui.button({ + key = "tab-crashes-" .. tostring(view == "crashes"), + text = tr("tab_crashes"), + variant = view == "crashes" and "primary" or "ghost", + flexGrow = 1, + onClick = function() + view = "crashes" + render() + end, + }), + ui.button({ + key = "tab-journal-" .. tostring(view == "journal"), + text = tr("tab_journal"), + variant = view == "journal" and "primary" or "ghost", + flexGrow = 1, + onClick = function() + view = "journal" + render() + request("journal", nil, LEVELS[levelIndex + 1]) + end, + }), + }), + } + + if snapshot ~= nil and snapshot.err ~= nil and snapshot.err ~= "" then + table.insert(children, ui.label({ text = tostring(snapshot.err), fontSize = 11, color = "error", maxLines = 2 })) + end + + if view == "journal" then + renderJournal(children) + else + renderCrashes(children) + end + + if snapshot ~= nil and snapshot.checkedAt ~= nil and snapshot.checkedAt ~= "" then + table.insert(children, ui.separator({})) + table.insert(children, ui.label({ + text = tr("caption_checked", { time = snapshot.checkedAt }), + fontSize = 10, color = "on_surface_variant", + })) + end + + panel.render(ui.column({ flexGrow = 1, gap = 8, align = "stretch" }, children)) +end + +function onOpen(_context) + snapshot = noctalia.state.get(STATE_KEY) + render() + request("refresh") +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then + return + end + snapshot = value + render() +end) diff --git a/crashes/plugin.toml b/crashes/plugin.toml new file mode 100644 index 00000000..d2292274 --- /dev/null +++ b/crashes/plugin.toml @@ -0,0 +1,81 @@ +id = "umedbazarov/crashes" +name = "Crashes" +version = "1.0.0" +plugin_api = 9 +author = "umedbazarov" +license = "MIT" +icon = "bug" +description = "systemd-coredump crash history: desktop notifications, per-program mutes and one-click AI diagnosis in a terminal." +dependencies = ["bash", "coredumpctl", "jq", "notify-send"] +tags = ["panel", "utility"] + +[[setting]] +key = "poll_seconds" +type = "int" +label_key = "settings.poll_seconds.label" +description_key = "settings.poll_seconds.description" +default = 20 +min = 5 +max = 600 + +[[setting]] +key = "notify" +type = "bool" +label_key = "settings.notify.label" +description_key = "settings.notify.description" +default = true + +[[setting]] +key = "agent_cmd" +type = "string" +label_key = "settings.agent_cmd.label" +description_key = "settings.agent_cmd.description" +default = "" + +[[setting]] +key = "agent_order" +type = "string" +label_key = "settings.agent_order.label" +description_key = "settings.agent_order.description" +default = "" + +[[setting]] +key = "probe_agent" +type = "bool" +label_key = "settings.probe_agent.label" +description_key = "settings.probe_agent.description" +default = true + +[[setting]] +key = "extra_prompt" +type = "string" +label_key = "settings.extra_prompt.label" +description_key = "settings.extra_prompt.description" +default = "" + +[[setting]] +key = "terminal_cmd" +type = "string" +label_key = "settings.terminal_cmd.label" +description_key = "settings.terminal_cmd.description" +default = "kitty -e" + +[[setting]] +key = "env_file" +type = "string" +label_key = "settings.env_file.label" +description_key = "settings.env_file.description" +default = "" +advanced = true + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 500 +height = 560 +placement = "floating" +position = "center" diff --git a/crashes/service.luau b/crashes/service.luau new file mode 100644 index 00000000..16c27ff9 --- /dev/null +++ b/crashes/service.luau @@ -0,0 +1,333 @@ +--!nonstrict +-- Crashes engine. Polls systemd-coredump (coredumpctl --json=short) on a +-- timer, keeps the recent-crash list as shared state, notifies about new +-- crashes of this user's processes, and owns the persisted files (last seen +-- timestamp, per-program mutes). The panel is a pure renderer that talks +-- back through "crashes_request". +-- +-- state "crashes_state" = { nonce, crashes, mutes, journal, checkedAt, err } +-- -- crashes: newest first: { pid, timeUsec, +-- -- timeLabel, sigName, exe, name, core, +-- -- muted } +-- -- journal: { level, entries } -- newest first: +-- -- { prio, prioName, name, timeLabel, msg } +-- requests "crashes_request" = { nonce, action, name, level } +-- -- refresh|mute|unmute|journal +-- +-- CPU budget care (25 ms per tick): listing and filtering happen in +-- jq/coredumpctl outside the runtime; the callback parses at most MAX_ROWS +-- short TSV lines. Notifications are fire-and-forget notify-send (no +-- blocking actions) — diagnosis lives in the panel. + +local STATE_KEY = "crashes_state" +local REQUEST_KEY = "crashes_request" + +local MAX_ROWS = 30 +local NOTIFY_DEDUPE_SECONDS = 60 +local STARTUP_DELAY_TICKS = 10 -- let the shell settle first + +local SIG_NAMES = { + [1] = "SIGHUP", [2] = "SIGINT", [3] = "SIGQUIT", [4] = "SIGILL", + [5] = "SIGTRAP", [6] = "SIGABRT", [7] = "SIGBUS", [8] = "SIGFPE", + [11] = "SIGSEGV", [13] = "SIGPIPE", [24] = "SIGXCPU", [25] = "SIGXFSZ", + [31] = "SIGSYS", +} + +-- syslog/journald priorities (see syslog(3)); the panel offers 0..3. +local PRIO_NAMES = { + [0] = "EMERG", [1] = "ALERT", [2] = "CRIT", [3] = "ERR", + [4] = "WARN", [5] = "NOTICE", [6] = "INFO", [7] = "DEBUG", +} + +local myUid = nil -- resolved once via the shell; polling waits for it + +local crashes = {} +local journal = nil -- { level, entries }; filled on the panel's request +local journalPolling = false +local mutes = {} -- name -> true +local checkedAt = "" +local errMsg = nil +local stateNonce = 0 +local lastRequestNonce = 0 + +local polling = false +local sincePoll = 0 +local startupTicks = 0 +local started = false + +-- Only the newest crash already announced (or present before the first +-- poll) — everything newer than this notifies. Persisted so a shell restart +-- does not re-announce old crashes. +local seenUsec = 0 +local seenLoaded = false + +local lastNotified = {} -- name -> epoch seconds + +local function cfg(key, fallback) + -- getConfig returns nil until the setting is materialized in + -- settings.toml; the manifest default is NOT substituted by the runtime. + -- An empty string is a real value (an intentionally empty terminal_cmd). + local value = noctalia.getConfig(key) + if value == nil then + return fallback + end + return value +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function dataPath(file) + local dir = noctalia.pluginDataDir() + if dir == nil then + return nil + end + return dir .. "/" .. file +end + +local function publish() + stateNonce += 1 + noctalia.state.set(STATE_KEY, { + nonce = stateNonce, + crashes = crashes, + mutes = mutes, + journal = journal, + checkedAt = checkedAt, + err = errMsg, + }) +end + +-- ── Persisted files ───────────────────────────────────────────────────────── + +local function loadPersisted() + local path = dataPath("seen.json") + if path ~= nil and noctalia.fileExists(path) then + local ok, parsed = pcall(noctalia.json.decode, noctalia.readFile(path)) + if ok and type(parsed) == "table" then + seenUsec = tonumber(parsed.seenUsec) or 0 + end + end + local mpath = dataPath("mutes.json") + if mpath ~= nil and noctalia.fileExists(mpath) then + local ok, parsed = pcall(noctalia.json.decode, noctalia.readFile(mpath)) + if ok and type(parsed) == "table" then + mutes = parsed + end + end + seenLoaded = true +end + +local function saveSeen() + local path = dataPath("seen.json") + if path ~= nil then + noctalia.writeFile(path, noctalia.json.encode({ seenUsec = seenUsec })) + end +end + +local function saveMutes() + local path = dataPath("mutes.json") + if path ~= nil then + noctalia.writeFile(path, noctalia.json.encode(mutes)) + end +end + +-- ── Polling ───────────────────────────────────────────────────────────────── + +local function notifyNew(entry) + if cfg("notify", true) ~= true then + return + end + local now = os.time() + if (now - (lastNotified[entry.name] or 0)) < NOTIFY_DEDUPE_SECONDS then + return + end + lastNotified[entry.name] = now + -- Fire-and-forget: no blocking action button; the panel is the + -- diagnosis surface. Crash details ride as discrete argv words. + noctalia.runAsync("notify-send --urgency critical --app-name crashes " + .. shellQuote(tr("notify_title", { name = entry.name })) .. " " + .. shellQuote(tr("notify_body", { signal = entry.sigName, pid = entry.pid })), function() end) +end + +local function poll() + if polling or myUid == nil then + return + end + polling = true + errMsg = nil + + -- jq keeps only this user's newest rows and emits short TSV; everything + -- heavy stays outside the runtime. + local cmd = "coredumpctl list --json=short --no-pager 2>/dev/null | jq -r --argjson uid " .. myUid + .. " '[.[] | select(.uid == $uid)][-" .. MAX_ROWS .. ":][] " + .. "| [.time, .pid, .sig, (.corefile // \"-\"), (.exe // \"-\")] | @tsv'" + + noctalia.runAsync(cmd, function(result) + polling = false + if result.timedOut or (result.exitCode or 1) ~= 0 then + -- No coredumps at all also exits non-zero; treat empty output + -- as an empty list rather than an error. + if (result.stdout or "") ~= "" and result.timedOut then + errMsg = tr("err_poll") + end + end + + local list = {} + local newestUsec = seenUsec + for line in (result.stdout or ""):gmatch("[^\n]+") do + local timeUsec, pid, sig, core, exe = line:match("^(%d+)\t(%d+)\t(%d+)\t([^\t]*)\t([^\t]*)$") + if timeUsec ~= nil then + timeUsec = tonumber(timeUsec) + local name = exe:match("([^/]+)$") or "unknown" + if name == "" or name == "-" or name == "." or name == ".." then + name = "unknown" + end + local entry = { + pid = pid, + timeUsec = timeUsec, + timeLabel = os.date("%d.%m %H:%M", math.floor(timeUsec / 1000000)), + sigName = SIG_NAMES[tonumber(sig)] or ("signal " .. sig), + exe = exe, + name = name, + core = core == "present", + muted = mutes[name] == true, + } + -- Newest first for the panel. + table.insert(list, 1, entry) + if timeUsec > seenUsec and mutes[name] ~= true then + notifyNew(entry) + end + if timeUsec > newestUsec then + newestUsec = timeUsec + end + end + end + + crashes = list + if newestUsec > seenUsec then + seenUsec = newestUsec + saveSeen() + end + checkedAt = os.date("%H:%M:%S") + publish() + end) +end + +-- Journal errors from the current boot at `level` and stricter (0=emerg..3=err). +-- Only on request: polling the journal continuously would be noise and +-- background work nobody asked for. +local function pollJournal(level) + if journalPolling then + return + end + journalPolling = true + level = math.max(0, math.min(3, tonumber(level) or 3)) + + -- @tsv escapes tabs and newlines inside MESSAGE; the gsub flattens them + -- first so every row stays a single short line. + local cmd = "journalctl -b -p " .. level .. " -n 40 -o json --no-pager 2>/dev/null | jq -r " + .. shellQuote('[(.PRIORITY // "-"), ((.SYSLOG_IDENTIFIER // ._COMM // "-") | tostring),' + .. ' (.__REALTIME_TIMESTAMP // "0"),' + .. ' ((.MESSAGE // "") | tostring | gsub("[\\n\\t]"; " ") | .[0:200])] | @tsv') + + noctalia.runAsync(cmd, function(result) + journalPolling = false + local entries = {} + for line in (result.stdout or ""):gmatch("[^\n]+") do + local prio, name, timeUsec, msg = line:match("^([^\t]*)\t([^\t]*)\t(%d+)\t(.*)$") + if prio ~= nil then + -- Newest first. + local secs = math.floor((tonumber(timeUsec) or 0) / 1000000) + table.insert(entries, 1, { + prio = tonumber(prio) or 7, + prioName = PRIO_NAMES[tonumber(prio)] or ("P" .. prio), + name = name ~= "" and name or "-", + timeLabel = os.date("%d.%m %H:%M", secs), + -- Exact timestamp, for journalctl --since/--until in a diagnosis. + timeIso = os.date("%Y-%m-%d %H:%M:%S", secs), + msg = msg, + }) + end + end + journal = { level = level, entries = entries } + publish() + end) +end + +-- ── Requests from the panel ───────────────────────────────────────────────── + +local function handleRequest(request) + if type(request) ~= "table" then + return + end + local nonce = tonumber(request.nonce) or 0 + if nonce <= lastRequestNonce then + return + end + lastRequestNonce = nonce + + local action = request.action + if action == "refresh" then + poll() + elseif action == "journal" then + pollJournal(request.level) + elseif action == "mute" and type(request.name) == "string" and request.name ~= "" then + mutes[request.name] = true + saveMutes() + for _, entry in ipairs(crashes) do + if entry.name == request.name then + entry.muted = true + end + end + publish() + elseif action == "unmute" and type(request.name) == "string" then + mutes[request.name] = nil + saveMutes() + for _, entry in ipairs(crashes) do + if entry.name == request.name then + entry.muted = false + end + end + publish() + end +end + +noctalia.state.watch(REQUEST_KEY, handleRequest) + +-- ── Tick ──────────────────────────────────────────────────────────────────── + +function update() + if not started then + startupTicks += 1 + if startupTicks < STARTUP_DELAY_TICKS then + return + end + started = true + loadPersisted() + -- The uid gates everything: crashes of other users are the + -- sysadmin's problem, not a desktop notification. + noctalia.runAsync("id -u", function(result) + local uid = tonumber((result.stdout or ""):match("%d+")) + if uid ~= nil then + myUid = uid + poll() + else + errMsg = tr("err_uid") + publish() + end + end) + return + end + + sincePoll += 1 + local every = tonumber(cfg("poll_seconds", 20)) or 20 + if sincePoll >= every then + sincePoll = 0 + poll() + end +end diff --git a/crashes/thumbnail.webp b/crashes/thumbnail.webp new file mode 100644 index 00000000..32e6acf4 Binary files /dev/null and b/crashes/thumbnail.webp differ diff --git a/crashes/translations/en.json b/crashes/translations/en.json new file mode 100644 index 00000000..7821adef --- /dev/null +++ b/crashes/translations/en.json @@ -0,0 +1,59 @@ +{ + "caption_checked": "checked {time}", + "empty": "No crashes recorded", + "empty_hint": "When one of your processes dumps core, it shows up here.", + "err_poll": "coredumpctl did not answer, see the Noctalia log", + "err_uid": "Could not resolve the user id, crash list disabled", + "journal_empty": "Nothing at this level in the current boot", + "level_alert": "alert and up", + "level_crit": "crit and up", + "level_emerg": "emerg only", + "level_err": "err and up", + "level_label": "Level", + "no_core": "core gone", + "notify_body": "{signal}, PID {pid} — open the Crashes panel to diagnose", + "notify_title": "Process crashed: {name}", + "settings": { + "poll_seconds": { + "label": "Poll interval (seconds)", + "description": "How often to re-read the systemd-coredump journal." + }, + "notify": { + "label": "Desktop notifications", + "description": "Announce new crashes of your processes with a critical notification. Turn off if something else already announces them." + }, + "agent_cmd": { + "label": "AI agent command", + "description": "Command that receives the diagnosis prompt as its last argument: \"claude\", \"codex\", \"opencode --prompt\", \"grok --\"… Empty = use the first installed well-known agent." + }, + "agent_order": { + "label": "Agent order", + "description": "Which agents to try and in what order, e.g. \"codex, claude\". Empty: claude, opencode, codex, grok." + }, + "probe_agent": { + "label": "Check the agent can answer", + "description": "Before opening the window, ask the agent a throwaway question; if it cannot answer — out of quota, logged out, offline — the next one is opened instead. Costs one tiny request per diagnosis. Off: the first installed agent is opened whether or not it works." + }, + "extra_prompt": { + "label": "Extra prompt", + "description": "Text appended to every diagnosis prompt — e.g. \"Answer in Russian\" or house rules for the agent." + }, + "terminal_cmd": { + "label": "Terminal command", + "description": "Wrapper that opens the agent in a window, e.g. \"kitty -e\". Leave empty if the agent command opens its own window." + }, + "env_file": { + "label": "Environment file", + "description": "Sourced before the agent starts. For a proxy or an API key your shell sets but a panel click does not inherit, e.g. ~/.config/agent-env." + } + }, + "tab_crashes": "Crashes", + "tab_journal": "Errors", + "tip_close": "Close", + "tip_diagnose": "Diagnose with AI in a terminal", + "tip_mute": "Stop notifying about this program", + "tip_no_core": "The core dump is gone (rotated away) — nothing to analyze", + "tip_refresh": "Re-read the coredump list now", + "tip_unmute": "Notify about this program again", + "title": "Crashes" +} diff --git a/crashes/translations/ru.json b/crashes/translations/ru.json new file mode 100644 index 00000000..a2ca9599 --- /dev/null +++ b/crashes/translations/ru.json @@ -0,0 +1,59 @@ +{ + "caption_checked": "проверено {time}", + "empty": "Крашей не записано", + "empty_hint": "Когда один из твоих процессов упадёт с дампом, он появится здесь.", + "err_poll": "coredumpctl не ответил, подробности в логе Noctalia", + "err_uid": "Не удалось определить uid, список крашей отключён", + "journal_empty": "На этом уровне за текущую загрузку пусто", + "level_alert": "alert и строже", + "level_crit": "crit и строже", + "level_emerg": "только emerg", + "level_err": "err и строже", + "level_label": "Уровень", + "no_core": "дамп удалён", + "notify_body": "{signal}, PID {pid} — диагностика в панели Crashes", + "notify_title": "Процесс упал: {name}", + "settings": { + "poll_seconds": { + "label": "Интервал опроса (секунды)", + "description": "Как часто перечитывать журнал systemd-coredump." + }, + "notify": { + "label": "Уведомления на рабочем столе", + "description": "Сообщать о новых крашах твоих процессов критическим уведомлением. Выключи, если о них уже сообщает что-то другое." + }, + "agent_cmd": { + "label": "Команда AI-агента", + "description": "Команда, получающая промпт диагностики последним аргументом: «claude», «codex», «opencode --prompt», «grok --»… Пусто = взять первый установленный из известных." + }, + "agent_order": { + "label": "Порядок агентов", + "description": "Кого пробовать и в каком порядке: например «codex, claude». Пусто — claude, opencode, codex, grok." + }, + "probe_agent": { + "label": "Проверять, что агент отвечает", + "description": "Перед открытием окна задать агенту пустяковый вопрос; если он не отвечает (кончились лимиты, не залогинен, нет сети), откроется следующий. Стоит одного крошечного запроса на диагностику. Выключено — открывается первый установленный, работает он или нет." + }, + "extra_prompt": { + "label": "Добавка к промпту", + "description": "Текст, добавляемый к каждому промпту диагностики — например, «Отвечай на русском языке»." + }, + "terminal_cmd": { + "label": "Команда терминала", + "description": "Обёртка, открывающая агента в окне, например «kitty -e». Оставь пустой, если команда агента сама открывает окно." + }, + "env_file": { + "label": "Файл окружения", + "description": "Подключается перед запуском агента. Для прокси или ключа, которые есть в вашей оболочке, но не наследуются при клике в панели: например ~/.config/agent-env." + } + }, + "tab_crashes": "Краши", + "tab_journal": "Ошибки", + "tip_close": "Закрыть", + "tip_diagnose": "AI-диагностика в терминале", + "tip_mute": "Не уведомлять об этой программе", + "tip_no_core": "Дамп уже удалён (ротация) — анализировать нечего", + "tip_refresh": "Перечитать список coredump сейчас", + "tip_unmute": "Снова уведомлять об этой программе", + "title": "Краши" +}