Skip to content

Notifications

Andrea de Ruvo edited this page Sep 8, 2026 · 15 revisions

Notifications

The question this answers is not "is the terminal quiet". It is "has it finished, or is it waiting for me" — and those two deserve different treatment, because one is news and the other is a stall. Anything that guesses from the output cannot tell them apart, which is why a notification that guesses becomes noise within a day.

So Argus does not guess. It rings when something tells it to.

Three ways to ring it

1. An agent hook — the exact signal

The short way: Settings → Let your agents ring. That button does everything in this section for you — writes the script, adds the hooks to each agent's own configuration — and the same button undoes it. It is additive on purpose: an event you have already claimed is reported and left exactly as it was, and a copy of each file as it was before Argus first touched it is kept next to it as .before-argus. Agents read their configuration when they start, so it counts from the next one you open.

The rest of this section is what the button does, for when you would rather do it yourself or the button found a hook of yours in the way.

Every agent this wires up can call a program when a turn ends, and the hook knows which of the two cases it is. It posts to Argus:

tools/argus-bell in the repository is this script, ready to copy into ~/bin. It reads the token from the Argus config rather than keeping a copy, finds the tmux session by itself, and unwraps the JSON codex passes to its notify program. In outline:

#!/bin/sh
# ~/bin/argus-bell — why: done | asking | failed | note
curl -s -X POST http://127.0.0.1:8090/api/bell \
  -H "Authorization: Bearer $ARGUS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"session\":\"$(tmux display -p '#S' 2>/dev/null)\",\"why\":\"${1:-done}\",\"text\":\"${2:-}\"}"

Claude Code — in ~/.claude/settings.json, Stop for a finished turn and Notification for one that wants you:

{
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "command": "~/bin/argus-bell done" }] }],
    "Notification": [{ "hooks": [{ "type": "command", "command": "~/bin/argus-bell asking" }] }]
  }
}

Gemini CLI — in ~/.gemini/settings.json, the identical shape as Claude Code's, under its own names: AfterAgent for a finished turn, Notification for the one case it currently raises one for, a tool permission prompt:

{
  "hooks": {
    "AfterAgent": [{ "hooks": [{ "type": "command", "command": "~/bin/argus-bell done" }] }],
    "Notification": [{ "hooks": [{ "type": "command", "command": "~/bin/argus-bell asking" }] }]
  }
}

Gemini passes its own JSON on stdin, not as an argument — session_id, cwd, notification_type, and the rest. argus-bell does not read it: it already asks tmux directly which session it is running in, so there is nothing in that payload it needs.

Codex — in ~/.codex/config.toml, notify is called with a JSON argument whose type is agent-turn-complete:

notify = ["/home/you/bin/argus-bell", "done"]

Hook schemas move between versions; if a key here does not match yours, the shape of the idea is still right — run one command, pass done or asking.

Grok Build needs nothing written for it at all, and Let your agents ring does not try to. It reads ~/.claude/settings.json on its own, by default, alongside its own ~/.grok/hooks/ — wiring Claude Code up here wires Grok Build up with it, for free. Turn this off in Grok's own config with [compat.claude] hooks = false, in which case the Claude Code hooks above stop reaching it and there is currently no Grok-specific equivalent in this wiki to fall back on — the two are meant to be read together.

2. The escape sequence — for everything that is not an agent

OSC 9 is what a program prints to say "tell the user", and every modern terminal implements it. Argus is a terminal, so it does too, along with OSC 777. A build, a pipeline, a script: one printf and no configuration.

tmux swallows it. Measured, not assumed: a plain OSC 9 printed inside tmux never reaches the client, with or without allow-passthrough. It arrives only wrapped in tmux's passthrough, and only with the option on:

# in ~/.tmux.conf — the tmux configuration editor in Settings can apply this for you
set -g allow-passthrough on
# put this in your shell profile and use `bell 'the run is done'`
bell() {
  if [ -n "$TMUX" ]; then printf '\033Ptmux;\033\033]9;%s\007\033\\' "$*"
  else printf '\033]9;%s\007' "$*"; fi
}

nextflow run main.nf; bell "the pipeline finished"

3. By hand

POST /api/bell with {"session": "...", "why": "done", "text": "..."} from anything at all — a cron job, a Makefile, another machine that can reach Argus.

What happens when it rings

  • the window that rang is outlined, green for finished and amber for waiting
  • the tab of the desk holding it gets a mark, so it is visible from another desk
  • a message you can tap to jump straight to that session
  • two short tones, switchable off in Settings
  • a notification from the browser, if you have allowed it

Looking at the window is what stops it: no dismissing, no clearing a list.

Choosing which sessions ring

Every terminal window has a bell in its title bar. Lit, that session rings; struck through, it keeps quiet.

Everything rings by default, and that is deliberate: a bell you have to switch on for each session is a bell that is silent on the day you needed it. What you switch off is the session that natters, or the one somebody else is watching.

Silence belongs to the session rather than to the desk or the window — it is the same tmux session wherever it is shown — so it holds everywhere and across reloads.

A quiet mark, which is not a bell

Everything above rings because something told it to. This is the one thing in Argus that guesses — deliberately, and only a little.

It cannot tell "finished" from "waiting for you" either; nothing watching a terminal can, which is the whole reason a hook exists and this is not one. What it can honestly say is narrower: this pane was printing steadily a moment ago, and has now said nothing for a while. Sometimes that is a question sitting unanswered, sometimes a job that quietly finished — either way, worth a glance sooner than a pane that has been silent for an hour, which is the ordinary resting state of most terminals and would light up right along with it if this only asked "how long since it last spoke". So it asks a second question first: was it actually busy just before it went quiet.

A small dim dot on the window, nothing else. No sound, no toast, no badge on any tab, and a colour no real bell uses — a mark meant to be glanced at or ignored, never one that pushes at anyone. It clears the moment the pane speaks again, and it clears when you type into it too, which is a stronger signal than merely looking: you can look at a stuck pane and still not have dealt with it.

This lives only on a window on the Wall — the mobile screen has nowhere to put a mark that isn't already a bell.

In another tab, without HTTPS

This is the case that matters, and it works over plain http://.

Bells arrive on an open stream, not by polling. A tab in the background has its timers throttled to roughly once a minute, so a poll would deliver the news long after it was any use; a message arriving on a connection that is already open is not throttled. Measured at about a second and a half from the hook firing to the tab reacting, with the tab hidden.

What a hidden tab can then do without any permission at all:

  • the tab title becomes ● session (or ✓ session when it merely finished)
  • a coloured dot is drawn onto the favicon — amber for waiting, green for finished. This is the one that matters in practice: with a dozen tabs open the strip shrinks each one down to its icon and the title is never shown at all
  • the sound plays

Both marks come off the moment you look at the tab.

Both work on plain http. Neither needs a certificate, a service worker or a permission prompt.

The browser's own notifications, without a certificate

The Notification API is refused outside a secure context. That is the browser's rule and no server-side trick gets round it — but most browsers let you declare that you trust one origin, which is a setting on your own machine and applies to nothing else.

Chrome, Edge, Brave, Opera

chrome://flags/#unsafely-treat-insecure-origin-as-secure

(edge://flags/…, brave://flags/… — the same flag under each browser's own scheme.)

Paste your Argus address exactly as it appears in the address bar, scheme and port included, and set the dropdown to Enabled, then relaunch:

http://10.0.0.5:8090

Several origins go in comma-separated. This is the best-behaved of the options: it takes a full origin, so the port is part of what you are trusting, and it changes nothing else about how the browser treats the rest of the web.

Firefox

about:config, then:

dom.securecontext.allowlist       10.0.0.5

A comma-separated list of hostnames — no scheme, no port. Firefox has no way to narrow this to one port, so you are trusting everything served from that host.

There is also a side effect worth knowing before you turn it on: Firefox then tries to upgrade the page's resource requests to HTTPS, which on a plain-http server means images and other assets simply fail. The workaround is the second preference:

security.mixed_content.upgrade_content    false

Safari

No equivalent, on macOS or iOS. Safari offers no way to declare an http origin trustworthy, and on iOS web notifications additionally require the app to be installed to the home screen. For Safari the answer is a real certificate, below.

A real certificate, which works everywhere

Two ways, neither of which needs a public domain:

  • tailscale serve --bg 8090 — a genuine certificate for a name inside your tailnet, no configuration and nothing to install in any browser.
  • mkcert — makes a local certificate authority, installs it in your own trust store, and issues a certificate for whatever name or IP you use. Entirely offline.

This is the only route that also covers phones, and the only one that turns Argus into an installable app on iOS.

Or skip the browser

None of the above is needed for a relay: point the hook at ntfy or Gotify as well and the notification arrives in a native app, with no certificate on this side at all. See below.

Notifications that arrive with the tab closed

This is where the honest answer is a caveat. A real browser notification needs a secure context, so over plain http:// the browser refuses outright — Settings says so rather than pretending. Over HTTPS (a certificate, or tailscale serve) it works, and on iOS only for an installed PWA.

For a phone with everything shut, the reliable route is a relay. Argus can deliver each bell to ntfy (self-hosted or hosted), while keeping the open-browser stream independent. Add this to ~/.config/argus/config.yaml and restart:

ntfy:
  server: https://ntfy.sh
  topic: argus-c83e9b12f4474a57    # make this long, random and yours
  on: [asking, failed, done]
  # token: tk_example              # for an authenticated/self-hosted topic

Subscribe to the same topic in the ntfy phone app. asking and failed arrive at urgent priority; done is a normal notification. The message contains only the session name and the hook's short text — never the Argus address or access token. Delivery is best-effort: an unavailable relay never holds up the agent hook or removes the bell from Argus.

On the public ntfy.sh, the topic name is effectively a password: anyone who guesses it can subscribe. Use a long random topic, or use an authenticated topic on your own ntfy server. The Argus config is mode 0600, but an ntfy token stored there is still readable by anything running as your account, just like the Argus token beside it.

Gotify and Pushover are not built in. A hook can still post to either in addition to Argus.

While you were away

Every other screen here answers what is happening. Since answers the one you open a phone for: did anything happen, and does it need me.

It is the interval, not the state. Since you last looked at it — this device's own idea of "last", because a phone checked at breakfast has no business telling the desk it has already seen the night's work.

Grouped by desk, not by category, and each one a card — border, rounded corners, its own colour as a dot in the header, the same colour its tab already wears. "Did anything happen with the salmonella run" is a question about a desk, not about whether it was a bell or a file — a desk is sessions plus a folder, which is exactly the two things every kind of news here is about, and reading four flat lists that mix every project together answers a question nobody asked. A bell from claude, a file written in the folder that desk opens to, a session started there: all of it inside one card, named and coloured after the desk. Within a desk, a question comes first — everything below it on this whole screen is news you can read or not read, and a question is a job that has stopped — then what finished or failed, then what started, then what was written.

A desk with an open question floats above the others; otherwise they keep the order they already sit in along the top of the Wall. Elsewhere is the tail: a bell from a session that was never added to any desk, or one that was and has since been removed from all of them — its card carries a dim, undecided dot instead of a colour it has no desk to take. A run is placed by its own agents — each is named after the session it runs in, so a run belongs wherever most of them do.

Each line opens the thing it names: the session, or the file. And each one leads with the same mark it would carry anywhere else in the app, rather than a screen where a session starting and a file being written looked like the same kind of line — a session's own coloured dot, the same one Sessions and Files already use for it; a file's real per-extension badge; an orchestration with no single session to point at gets a tinted relay mark instead. The same session is the same dot everywhere — on its tab, on its desk's card, on its own line here — which is most of what makes a page of otherwise similar rows readable at a glance, and the reason the coloured edge every row used to carry on its own is gone: one signal per row, not the same fact said twice.

Nothing happened is an answer, and it is printed as one. Most mornings it is the one you want.

Seen on the way in, not on the way out: what is on the screen is what happened before you opened it, and anything arriving while you read rings on its own, as it always does. The count on the tab is bells this page has heard since that mark — bells that rang while the browser was shut are on the server and appear when you open the screen, so the badge stays quiet rather than inventing a number.

The walk over the folders has a search's brakes on it: .git, node_modules, a conda install and the rest are pruned, dotfiles are somebody's tool talking to itself, folders are never reported — a folder's time changes whenever anything inside it does — and it stops at forty files. A desk whose folder has been moved is skipped rather than refused: a summary that fails because one desk went stale is not a summary.

A file is placed by the deepest desk folder it sits under, not the first one that matches. One desk's home is often a parent of another's — a workspace root above several project folders open as their own desks — and a file inside the project folder is that project's news, not the root's.

Clone this wiki locally