Omahub
← All plugins
I

Claude Alerts

by Idan Deshe

Sound, notification, and a bar badge when a Claude Code agent in a devcontainer needs you.

Security review

Potentially dangerous behavior detected · 9 findings

Deterministic scan — not a security guarantee

High
Risk level
High
Analyzed commit
22f500a
Scanned
1 month ago
  • Bundles a systemd unit file.

    [Unit]
  • Command runs with sudo, elevating the process beyond the plugin environment.

    sudo pacman -S nodejs   (needs Node 22 or newer)"
  • Docs external_hosts README.md:182

    Downloads or connects to an external HTTP(S) host.

    curl -s -X POST http://172.17.0.1:8787/clear \
  • Docs external_hosts README.md:284

    Downloads or connects to an external HTTP(S) host.

    curl -s -X POST http://127.0.0.1:8787/alert -H "X-Alert-Token: $T" \
  • Docs external_hosts README.md:375

    Downloads or connects to an external HTTP(S) host.

    curl -m5 http://172.17.0.1:8787/health` — a `401` is a *good* sign, it means the port is reachable |
  • Docs sudo README.md:31

    Command runs with sudo, elevating the process beyond the plugin environment.

    sudo ufw allow from 172.16.0.0/12 to any port 8787 proto tcp comment 'claude-alerts'
  • Docs sudo README.md:42

    Command runs with sudo, elevating the process beyond the plugin environment.

    sudo pacman -S nodejs`** |
  • Docs sudo README.md:60

    Command runs with sudo, elevating the process beyond the plugin environment.

    sudo firewall-cmd --permanent --add-rich-rule='rule
  • Docs sudo README.md:346

    Command runs with sudo, elevating the process beyond the plugin environment.

    sudo ufw delete allow from 172.16.0.0/12 to any port 8787 proto tcp

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
22f500a
Reviewed
1 month ago

The plugin is a local HTTP alert service that listens on loopback and the Docker bridge gateway, with token authentication, and the QML/TypeScript code is straightforward and non-obfuscated. The deterministic scan's high rating comes mostly from documentation snippets (curl, sudo, firewall) and the presence of a systemd unit, none of which execute during plugin install. The main residual risk is that the service binds to 172.17.0.1 and relies on a token that users must copy into container configs, but the code handles auth and file permissions carefully.

  • The service binds to 172.17.0.1 (Docker bridge) and accepts alerts from any container on the host's Docker networks; security depends on the user keeping the token secret and on the firewall rule being scoped to 172.16.0.0/12 as documented.
  • The bundled systemd unit (systemd/claude-alerts.service) is optional and not installed by the plugin, but a human should confirm it is only used intentionally on headless hosts.
  • The README's sudo/firewall/curl examples are documentation only and are not executed by the plugin; they are appropriate for the user to run manually if they want container-to-host connectivity.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/idandeshe/omarchy-claude-alerts --enable
Developer Tools #bar #ai

Claude Alerts

An Omarchy plugin that tells you when a Claude Code agent needs you — with a sound, a desktop notification, and a bar badge naming which project is waiting.

Built for running several agents at once in devcontainers, where the container has no practical access to the host's sound system.

bar:  ◉ 2

panel:
  checkout-service — wants permission    2m
  Wants to run: npm test
  api-gateway — is waiting on you        18s
  Waiting for your reply

Click a row to focus that project's window and drop it from the list.

Install

omarchy plugin add https://github.com/idandeshe/omarchy-claude-alerts.git --enable

Then allow containers through the firewall (once), and copy your token:

sudo ufw allow from 172.16.0.0/12 to any port 8787 proto tcp comment 'claude-alerts'
cat ~/.config/claude-alerts/token

Nothing is installed inside your containers.

Dependencies

Needs For Ships with Omarchy?
Omarchy 4 (Quattro) / omarchy-shell The bar widget, and clickable toasts yes
Node 22 or newer The alert service itself no — sudo pacman -S nodejs
curl, jq claude-alerts-ctl yes
libnotify (notify-send) Notifications off Omarchy yes
libcanberra (canberra-gtk-play), or libpulse (paplay) Playing the sound yes
hyprctl Focusing a window on click yes

Node is the only thing you are likely to be missing. The service runs the TypeScript directly — there is no build step and no node_modules.

About the firewall step

Omarchy's ufw defaults to DEFAULT_INPUT_POLICY="DROP", and container→host traffic hits the INPUT chain. The ufw-docker rules in /etc/ufw/after.rules only govern FORWARD, so they do not cover this — without that rule, containers time out connecting to the host.

172.16.0.0/12 spans every Docker bridge subnet and overlaps neither a typical LAN (192.168.x) nor a VPN (10.x). Only containers on your machine can reach the port. On firewalld: sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=172.16.0.0/12 port port=8787 protocol=tcp accept'.

Wiring a project

Paste into the project's .claude/settings.local.json — gitignored, so the token stays out of git:

{
  "hooks": {
    "Notification": [
      { "hooks": [ {
          "type": "http",
          "url": "http://172.17.0.1:8787/hook",
          "timeout": 5,
          "headers": { "X-Alert-Token": "PASTE_TOKEN_HERE" }
      } ] }
    ],
    "StopFailure": [
      { "hooks": [ {
          "type": "http",
          "url": "http://172.17.0.1:8787/hook",
          "timeout": 5,
          "headers": { "X-Alert-Token": "PASTE_TOKEN_HERE" }
      } ] }
    ]
  }
}

Claude Code's native "type": "http" hook does the POST itself, which is why nothing needs installing in the container. 172.17.0.1 is the docker0 gateway: reachable from containers on any bridge network, not from your LAN.

To commit hooks to a repo instead, put them in .claude/settings.json with "headers": {"X-Alert-Token": "$CLAUDE_ALERT_TOKEN"} and "allowedEnvVars": ["CLAUDE_ALERT_TOKEN"], then set that var via remoteEnv in devcontainer.json. Interpolation only resolves names listed in allowedEnvVars; omit it and the header silently comes through empty.

Which events

Notification is the umbrella event. It fires for every case where Claude is waiting on a human, tagged with a notification_type:

notification_type Meaning Alert
permission_prompt Wants permission to run something window-attention, critical
idle_prompt Idle, waiting on you window-question, critical
agent_needs_input Blocked needing input window-question, critical
elicitation_dialog An MCP server is asking you something window-question, critical
elicitation_url_dialog Needs you to open a URL window-question, critical
agent_completed An agent finished complete, normal
quota_auto_resume_stale / _disabled Quota resume needs you dialog-warning, critical
elicitation_complete, elicitation_response You answered an elicitation clears the badge, silently
auth_success Informational ignored

Wire Notification and every attention case is covered. Alongside it:

Event Fires when Wire it?
StopFailure The turn died on an API error Yes — not covered by Notification
TeammateIdle An agent-team teammate is going idle Yes — only fires if you use teams
Stop The agent finished its turn Optional; also clears it from the bar
PermissionRequest A permission decision is needed No — documented duplicate of Notification:permission_prompt, and it blocks the agent
Elicitation MCP server requesting input No — duplicate of Notification:elicitation_dialog, and blocking
TaskCompleted, SubagentStop Completion Optional; SubagentStop is noisy if you fan out

The blocking events sit on the agent's critical path and buy no extra signal. Wire them anyway and they share a debounce group with their Notification twin, so you still get one alert per real dialog.

Clearing it by answering

An alert that stays lit after you have dealt with it is an alert you stop trusting. These hooks put the badge out on their own, and none of them makes a sound or raises a toast — they only change the badge:

"UserPromptSubmit": [
  { "hooks": [ { "type": "http", "url": "http://172.17.0.1:8787/hook",
                 "timeout": 5, "headers": { "X-Alert-Token": "PASTE_TOKEN_HERE" } } ] }
],
"PostToolUse": [
  { "matcher": "AskUserQuestion",
    "hooks": [ { "type": "http", "url": "http://172.17.0.1:8787/hook",
                 "timeout": 5, "headers": { "X-Alert-Token": "PASTE_TOKEN_HERE" } } ] }
],
"PostToolBatch": [
  { "hooks": [ { "type": "http", "url": "http://172.17.0.1:8787/hook",
                 "timeout": 5, "headers": { "X-Alert-Token": "PASTE_TOKEN_HERE" } } ] }
]
Hook Clears when
PostToolUse (matcher AskUserQuestion) You answered Claude's question
UserPromptSubmit You typed a prompt — the real "I'm back" signal
PostToolBatch The agent resumed work, which is how an approved permission prompt is noticed
ElicitationResult You answered an MCP elicitation

elicitation_complete and elicitation_response need no hook of their own — they already arrive on the Notification hook.

Why PostToolBatch is in that list. Claude Code has no "permission approved" event; only PermissionDenied exists, and it covers auto-mode denials. An approval is silent, so the only way to notice you granted one is that the agent started working again. PostToolBatch fires once per batch rather than once per tool, which keeps that cheap.

Its trade-off: it means "the agent did some work", not "you answered". With parallel work in flight it can clear the badge while something else is still genuinely blocked. Drop it from a project's hooks if that bothers you — the other signals have no such ambiguity, and nothing in the service changes.

Clearing it from the agent

Automatic clearing is the reliable path; this is a supplement for when the agent should say "I am done asking". It depends on the model choosing to run it:

curl -s -X POST http://172.17.0.1:8787/clear \
  -H "X-Alert-Token: $CLAUDE_ALERT_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"project":"my-project"}'

Two things make it work in practice:

  • Pass the token in with "remoteEnv": { "CLAUDE_ALERT_TOKEN": "…" } in devcontainer.json, so no secret sits in the command.
  • Allow the command in permissions.allow. Otherwise the agent's curl raises a permission prompt — which fires a Notification and lights the badge. The request to clear the alert would create one.

Why you don't get alert spam

Running many agents at once is the design centre, so suppression happens in four layers:

  1. uuid — exact. The same trigger sent twice alerts once.
  2. group — time-windowed. Claude Code fires overlapping events for one real moment (a permission dialog raises Notification and PermissionRequest); rules sharing a group collapse into one alert.
  3. Playback — sounds play one at a time through a capped queue, and notifications carry a per-project tag so one chatty agent replaces its own notification instead of stacking.
  4. The badge — the durable half. A sound you miss while away is gone; the bar keeps naming who is waiting until you deal with them.

Clicking the notification

Click the toast and you land on that project's VS Code window; the entry clears itself at the same time. That is the fastest path from "something needs me" to being there.

This uses omarchy-notification-send --exec, which stores the click command as a hint the shell runs detached — so the toast stays clickable after the sender has exited, and still works from notification history. A plain libnotify action cannot do that: the shell only invokes it while the sender is alive.

Off Omarchy the toast still appears via notify-send, just without the click.

The window is found by slug-normalizing both the project name and the window title before comparing, because the two are written differently — project checkout-service against a title reading … checkout (Workspace) [Dev Container: Checkout Service] …. If several windows match, the most recently focused one wins.

The bar widget

Shows a count of agents blocked on you, and takes no space at all when none are. Click for the list; click a row to focus that project's window (matched on the window title) and clear it.

An entry clears when you click it, when you press Clear all, or on its own when the agent moves on — a Stop or TaskCompleted for that project.

The waiting list survives a shell restart: an agent that was blocked before is still blocked. Entries older than 12 hours are dropped instead.

Command line

bin/claude-alerts-ctl lives in the plugin folder. To use it by name, link it once:

ln -s ~/.config/omarchy/plugins/idan.claude-alerts/bin/claude-alerts-ctl ~/.local/bin/
claude-alerts-ctl state             # what is waiting on you
claude-alerts-ctl focus <project>   # focus that window and clear it
claude-alerts-ctl clear [project]   # clear one, or all
claude-alerts-ctl health            # service liveness
claude-alerts-ctl test              # fire a test alert

API

Method Path Purpose
POST /hook Claude Code hook payload. Always answers 200 {}
POST /alert Generic alert for scripts/CI
POST /clear Clear one project ({"project":"x"}) or all ({})
GET /state The waiting list
GET /health Full diagnostic with the token. Without it, loopback gets {"ok":true} and nothing else
POST /test Fire a synthetic alert

Auth is X-Alert-Token: <token> or Authorization: Bearer <token>.

/alert reports the outcome — sent, duplicate, debounced or ignored:

{ "ok": true, "status": "sent", "uuid": "…", "project": "my-app", "event": "Stop" }

Pass a uuid to make a trigger idempotent; a repeat within uuidTtlMs (default 60s) is ignored. Any string works. Omit it and one is generated, which never collides and so never suppresses anything.

T=$(cat ~/.config/claude-alerts/token)
curl -s -X POST http://127.0.0.1:8787/alert -H "X-Alert-Token: $T" \
  -H 'Content-Type: application/json' \
  -d '{"event":"Stop","project":"my-app","message":"Deployed","uuid":"deploy-run-7"}'

Two properties /hook guarantees, because it sits on the agent's critical path: it responds before dispatching, and returns 200 even on a malformed body. A broken alert must never make an agent look broken.

Configuration

config.json in the plugin folder is the baseline; drop a ~/.config/claude-alerts/config.json to override it. Rules merge per event, so you can retune one sound without restating the table. Edits apply live.

{
  "port": 8787,
  "bind": ["127.0.0.1", "172.17.0.1"],
  "debounceMs": 3000,
  "uuidTtlMs": 60000,
  "soundQueueMax": 5,
  "rules": {
    "Notification:idle_prompt": { "sound": "window-question", "level": "critical", "title": "is waiting on you", "group": "attention" },
    "Notification:auth_success": { "ignore": true }
  }
}

Rule keys resolve most-specific first: Notification:idle_prompt beats Notification beats defaultRule.

  • sound — any name from /usr/share/sounds/freedesktop/stereo (without .oga)
  • level — low | normal | critical; maps to notification urgency
  • group — debounce bucket; rules sharing one collapse into a single alert
  • state — waiting | clear | none; what it does to the bar list
  • ignore — never alert on this

Adding an alert channel

Channel in src/types.ts is the extension point:

export type Channel = { name: string; send(alert: Alert): Promise<void> }

Write src/channels/push.ts, add it to the array in src/channels/index.ts, then name it in a rule's channels. Nothing else changes.

Removing

omarchy plugin remove idan.claude-alerts

That stops the service and removes the plugin. It leaves your data alone, so clean up whatever you no longer want:

rm -rf ~/.config/claude-alerts              # config and token
rm -rf ~/.local/state/omarchy/claude-alerts # the waiting list
rm -f  ~/.local/bin/claude-alerts-ctl       # the CLI symlink, if you made one
sudo ufw delete allow from 172.16.0.0/12 to any port 8787 proto tcp

Then delete the hooks blocks you added to any project's .claude/settings.local.json. If you installed the optional systemd unit, systemctl --user disable --now claude-alerts and remove it from ~/.config/systemd/user/.

Nothing else is touched: the plugin never edits your Omarchy or Claude Code configuration for you.

Running without Omarchy

The service is a plain Node program; the plugin only supervises it. On a headless host, or one not running the Omarchy shell, use the unit in systemd/:

cp systemd/claude-alerts.service ~/.config/systemd/user/
systemctl --user enable --now claude-alerts

Running both is harmless — whichever starts second finds the port taken and exits.

Troubleshooting

Symptom Check
Container times out The ufw rule. docker exec <c> curl -m5 http://172.17.0.1:8787/health — a 401 is a good sign, it means the port is reachable
No sound canberra-gtk-play -i window-attention on the host
No notification notify-send test body
Widget never appears It hides when nothing is waiting. claude-alerts-ctl state
Only the first of several alerts Working as intended — debounceMs
401 Token mismatch; re-copy ~/.config/claude-alerts/token
Service not running journalctl --user -f | grep claude-alerts, or run bin/claude-alerts-server by hand

License

MIT