Notifications - andreaderuvo/argus GitHub Wiki

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 the exact signal comes from whoever knows: the agent's own hook. On top of that, Argus watches every agent's pane by itself (see Agents): an agent that was drawing and has stopped has ended its turn, and that rings too. So an agent with no hook, such as a Gemini nobody wired or a Codex whose hooks you have not reviewed yet, is not silent any more.

One ring per turn

An interactive agent that has finished a turn is waiting for you exactly as much as one that asked a question, so Argus treats "finished" and "waiting for you" from the same agent as one event per turn. The first to arrive rings, whether it is the hook or Argus noticing the pane stop; the rest of that turn ring nothing. A turn begins when the agent is seen working again.

This exists because of a measurement: before it, every Claude Code turn rang twice — done from its Stop hook, then asking exactly sixty seconds later, which is Claude Code's own "still waiting for your input" notification about a turn that had already rung. Two tones, twice, for one thing.

An agent with hooks is believed over its pane. Watching a pane cannot always tell: a Claude running a long command can leave its pane still for minutes, which read as waiting, and then each time the command's output landed it looked like a turn ending, and rang. So the hooks come first and the pane is the fallback. Claude Code and Codex are also wired with UserPromptSubmit (a turn has started, which rings nothing). Between that and the hook that says it stopped, the agent is working, whatever the pane does.

The pane never rings for a Claude Code or Gemini whose end-of-turn hook is Argus's. Codex is different: it runs its hooks only after you have reviewed them in Codex, and Argus cannot see that review. So a wired Codex is not counted as hooked until its hooks have actually been heard, and until then its pane still rings. Settings → Let your agents ring or the Argus plugin adds the hooks; an agent already open picks them up when it is next started.

Never folded: failed, note, a permission prompt in the middle of a turn (the agent was working, so it is a new stop), and anything from a session that is not an agent.

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.

Or install the Argus plugin, from the agent. The same hooks and the Argus MCP tools come in one install, for Claude Code and Codex:

Claude Code   /plugin marketplace add andreaderuvo/argus
              /plugin install argus@argus
Codex         codex plugin marketplace add andreaderuvo/argus
              codex plugin add argus@argus

The plugin's hooks are UserPromptSubmit (start), Stop (done — through argus-stop, which in a team first makes the agent report its turn), Notification and PermissionRequest (asking), and SessionStart (which plugin version the session runs). Codex asks you to review them once. Gemini CLI gets only the tools from the plugin (an extension); its hooks still come from the Settings button. See The Argus plugin.

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 — POST /api/bell with {session, why, text}:

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: start | done | asking | failed | note
# (the real one reads the port, https and the token from ~/.config/argus/config.yaml,
#  and names the tmux session only when it runs inside tmux)
curl -s -X POST http://127.0.0.1:8080/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": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "~/bin/argus-bell start" }] }],
    "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" }] }]
  }
}

Claude Code and Gemini pass their own JSON on stdin, not as an argument — session_id, cwd, message, and the rest. argus-bell reads two things from it, waiting at most half a second: the message of a notification, so the bell says what is wanted ("Claude needs your permission to use Bash") rather than only that something is; and the session_id, which is the conversation the agent is in and what lets Argus resume it after a reboot (see Sessions and the terminal). Which tmux session it is, it asks tmux directly.

Codex — since 0.160 it has Claude Code's hooks, in ~/.codex/hooks.json: Argus wires UserPromptSubmit (start), Stop (done, with its last words) and PermissionRequest (asking, naming the command). Codex runs them only after you review them once, in Codex. Until Argus hears them, the pane is still watched for that Codex, and the older notify in ~/.codex/config.toml rings at the end of a turn — 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; Argus has no Grok-specific wiring of its own, so then nothing rings for Grok. (Grok's behaviour is as its documentation describes it — Argus neither tests nor ships anything for it.)

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 in the bottom-right corner you can tap to jump straight to that session (a question waits there with its answers instead)
  • two short tones, switchable off in Settings
  • a notification from the browser, if you have allowed it
  • with ntfy configured, a notification on your phone, even with every browser shut: the server keeps watching the agents with nobody looking

Looking at the window stops it, and so does the agent getting back to work: once it is working again, the turn it rang about is over, whichever device you answered from. If you have seen it and nothing needs doing, Got it, all in the tab strip sets every waiting agent aside at once, or Got it on the desk card (see Desks and windows) sets that wait aside on every device until the agent has worked and stopped again.

The corner: messages and questions

Everything Argus tells you on screen arrives in the bottom-right corner (across the bottom on a phone), stacked — newest at the bottom, each one whole, never cut. A message fades after the time set in Settings → Interruptions → How long they stay: 3 seconds by default, or 5, 10, 20, by their length, or until I close them. The pointer on one holds it; ✕ closes it. Notifications on screen off keeps only the ones saying something failed.

A question — an agent's ask, or its request for your OK with Do it / No — is a card in the same corner that does not fade: it waits with its answers until you answer, here or on any other device, and is back after a reload while it is still open. ✕ only hides it on this page; it stays in While you were away.

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 — and only on a window whose session is not an agent. For an agent's window Argus knows working from waiting, and the guess would only argue with it.

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:8080          (your machine's address and Argus's port)

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 8080 (Argus's port) — 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]          # any of asking, failed, done, note
  # token: tk_example              # for an authenticated/self-hosted topic

The topic is 1–128 characters, no spaces. 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.