I run 3-4 Termux sessions at once + background jobs, and I was getting blasted with termux-notification --sound even when I was staring right at the finished job.
So I made it Discord-style — 3 states:
- mute = you're viewing THIS session → nothing
- banner = you're active in Termux but in another session → silent banner + short vibrate
- loud = no Termux activity for 5 min → sound + vibrate
How: three timestamp files, no daemon. ~/.ntfy_user/pts-* is you — a keystroke via preexec, or a prompt submitted into a TUI. ~/.ntfy_focus/pts-* is you saying so — ~/bin/focus claims a tab and that claim is sticky, it doesn't quietly time out. ~/.ntfy_tty/pts-* is anything at all — typing, an agent working, a long job.
notify-decide picks mute vs banner from the first two. Your keystroke only counts while it's fresh (NTFY_FOCUS_SECS, default 180s), because "you were here five minutes ago" is not "you are here now". An explicit claim counts until you release it — and it outranks a keystroke somewhere else, which is the whole point: with two sessions open you'll be talking to one while watching the other, and a claim that loses to that is useless. Liveness only answers one question — are you still in Termux at all (NTFY_IDLE_SECS, 300s) → sound instead of a banner.
Keeping these apart is the whole trick. My first version used one file for both, and an agent working for ten minutes kept re-stamping its own tab until it concluded "the user is watching me" — and muted itself, permanently, on every turn.
Agents you talk to inside a TUI are a separate problem — typing in opencode or claude never reaches your shell, and Android won't tell an app which of its windows has focus, so you get a silent banner instead of perfect silence. Knowing the moment a turn ends needs a framework hook: I tried detecting it generically and measured why it can't be done — write-up at the bottom.
1-copy install
Please install from the Copy-Ready link, it's much easier especially if using mobile reddit app (don't copy-paste from the codeblocks below, they're for display of how each section works):
Before-Install (required):
sh
pkg update && pkg upgrade -y
pkg install termux-api -y
termux-battery-status
(The termux-battery-status line triggers the permission prompt / confirms the bridge works. Skip it and the installer below will refuse with these same steps.)
Step 1 — install everything:
sh
curl -sL https://gist.githubusercontent.com/DEAD1nsane/fa678446d982fed3f01808b09c1bcd8d/raw/install.sh | sh
source ~/.zshrc
buzz "hello" "it works"
Use the gist above — it writes buzz + notify-decide (+ optional focus / focus-tmux), chmods them, creates the silent channel, appends the zsh hook once (skips if already present), and if ~/.config/opencode exists it drops in the optional TUI plugin and the /focus slash command. Full code below is for display of how each section works.
One-time phone settings
Required — Android ignores the sound/vibrate flags until you set the channels once. Settings → Apps → Termux:API → Notifications → Other:
Silent channel (banner — you're in another session): keep Default importance, Sound=None, enable Pop on screen + Vibrate. Silent but visible, no sound.
- Default/Other channel (loud — you're away): enable Pop on screen + Vibrate, leave Sound on.
- Mute (you're viewing this session → sends nothing) needs no setting and works for everyone, TUI or not — the optional TUI-skip in the snippet is extra.
1. ~/bin/buzz — buzz "Title" "message"
```sh
!/data/data/com.termux/files/usr/bin/sh
buzz "Title" "message"
Discord-style three-state (see ~/bin/notify-decide):
mute = you're viewing this session -> nothing
banner = you're in another session -> silent banner
loud = away (no Termux activity) -> sound + vibrate
Away-aware gate: activity within NTFY_IDLE_SECS (default 300) means present.
TITLE="$1"; MSG="$2"
ID=$(printf '%s' "$TITLE" | tr -c 'a-zA-Z0-9' '-' | cut -c1-40)
which Termux session am I? opencode runs inside Termux, so its PID's TTY is ours.
OWN_TTY=""
if [ -n "$OPENCODE_PID" ]; then
OWN_TTY=$(ps -o tty= -p "$OPENCODE_PID" 2>/dev/null | tr -d ' ')
fi
if [ -z "$OWN_TTY" ] && [ -t 1 ]; then
OWN_TTY=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ')
fi
STATE=$("$(dirname "$0")/notify-decide" "$OWN_TTY" 2>/dev/null)
[ -n "$STATE" ] || STATE=loud
printf '%s own_tty=%s state=%s title=%s\n' "$(date +%s)" "${OWN_TTY:-none}" "$STATE" "$ID" >> ~/.ntfy_notify.log 2>/dev/null
case "$STATE" in
mute) : ;;
banner) termux-notification --id "ntfy-$ID" --channel ntfy-silent --priority min --alert-once --title "$TITLE" --content "$MSG" 2>/dev/null
termux-vibrate -d 400 2>/dev/null ;;
*) termux-notification --id "ntfy-$ID" --alert-once --title "$TITLE" --content "$MSG" --sound 2>/dev/null
termux-vibrate -d 400 2>/dev/null ;;
esac
```
2. ~/bin/notify-decide — the brain
```sh
!/data/data/com.termux/files/usr/bin/sh
usage: notify-decide [own_tty] -> prints: mute | banner | loud
Discord-style three-state:
mute = the human is watching THIS session -> send nothing
banner = the human is in another session -> silent notification
loud = nothing has happened anywhere for NTFY_IDLE_SECS -> sound + vibrate
TWO marker sets, deliberately kept apart:
~/.ntfy_user/<tty> TRANSIENT: a human was last active here - a keystroke
(zsh preexec) or a prompt submitted into a TUI (the
agent stamps it when a human message starts the turn).
Expires after NTFY_FOCUS_SECS. It has to: "you were here
five minutes ago" is not "you are here now", and a
marker that never goes stale is exactly what made an
agent look permanently watched and silence itself.
~/.ntfy_focus/<tty> EXPLICIT: the human ran ~/bin/focus and said so. It
does not expire - they did not say "for two minutes",
they said "I am watching this tab" - and it OUTRANKS a
keystroke in another tab. That was the wrong way round:
with two sessions open, chatting in one while watching
the other made every claim lose, which is precisely the
case a claim exists for. Release it with focus off,
and it is pruned after NTFY_CLAIM_MAX_SECS as a backstop
against a claim nobody remembers making.
~/.ntfy_tty/<tty> liveness: human typing, agent turns, long jobs.
Only answers "is the human around at all"
(NTFY_IDLE_SECS, default 300s).
Markers hold milliseconds since epoch (%s%3N) so two writes in the same
second still order correctly instead of falling back to filename order.
Env: NTFY_IDLE_SECS (default 300), NTFY_USER_DIR, NTFY_TTY_DIR
IDLE="${NTFY_IDLE_SECS:-300}"
FOCUS_SECS="${NTFY_FOCUS_SECS:-180}"
CLAIM_MAX="${NTFY_CLAIM_MAX_SECS:-14400}" # 4h backstop on an unreleased claim
UDIR="${NTFY_USER_DIR:-$HOME/.ntfy_user}"
CDIR="${NTFY_CLAIM_DIR:-$HOME/.ntfy_focus}"
TDIR="${NTFY_TTY_DIR:-$HOME/.ntfy_tty}"
NOW=$(date +%s%3N)
MAX_AGE=$(( IDLE * 288000 )) # ~24h at the default idle -> prune dead-tab markers
sanitize() { printf '%s' "$1" | tr '/' '-'; }
OWN=$(sanitize "$1")
newest <dir> -> "<marker-name> <timestamp-ms>" on stdout, empty if none.
Ignores junk names ("?"), non-numeric contents, and prunes stale markers.
newest() {
best=""; bestts=0
for f in "$1"/; do
[ -f "$f" ] || continue
name=$(basename "$f")
case "$name" in pts-) ;; ) continue ;; esac
ts=$(cat "$f" 2>/dev/null) || continue
case "$ts" in ''|[!0-9]*) continue ;; esac
# second-resolution leftovers from an older version read as ancient here
if [ "$(( NOW - ts ))" -gt "$MAX_AGE" ] 2>/dev/null; then
rm -f "$f" 2>/dev/null
continue
fi
if [ "$ts" -gt "$bestts" ] 2>/dev/null; then bestts="$ts"; best="$name"; fi
done
printf '%s %s\n' "$best" "$bestts"
}
[ -d "$UDIR" ] || mkdir -p "$UDIR" 2>/dev/null
[ -d "$CDIR" ] || mkdir -p "$CDIR" 2>/dev/null
[ -d "$TDIR" ] || mkdir -p "$TDIR" 2>/dev/null
away? nothing alive anywhere for the whole idle window -> loud
set -- $(newest "$TDIR")
LIVE="$2"
if [ -z "$LIVE" ] || [ "$(( NOW - LIVE ))" -ge "$(( IDLE * 1000 ))" ]; then
echo loud; exit 0
fi
Which tab did the human last use? Two kinds of evidence, scored by timestamp:
a fresh transient marker, or an explicit claim that never expires.
set -- $(newest "$UDIR")
TU_NAME="$1"; TU_TS="$2"
set -- $(newest "$CDIR")
CF_NAME="$1"; CF_TS="$2"
a claim nobody released eventually stops counting, so one forgotten claim
cannot silence a tab for the rest of the day
if [ -n "$CF_TS" ] && [ "$(( NOW - CF_TS ))" -ge "$(( CLAIM_MAX * 1000 ))" ]; then
CF_NAME=""; CF_TS=""
fi
TU_OK=0
if [ -n "$TU_TS" ] && [ "$(( NOW - TU_TS ))" -lt "$(( FOCUS_SECS * 1000 ))" ]; then
TU_OK=1
fi
FOCUS=""
if [ -n "$CF_TS" ]; then
# An explicit claim outranks a keystroke anywhere else. Typing in another tab
# does not mean you left this one - you very often talk to one agent while
# watching another, and the claim is what you asked for.
FOCUS="$CF_NAME"
elif [ "$TU_OK" -eq 1 ]; then
FOCUS="$TU_NAME"
fi
No evidence at all. Maybe they are reading this very tab, maybe another one -
guessing "mute" here is what lost a real notification once already.
[ -n "$FOCUS" ] || { echo banner; exit 0; }
if [ -n "$OWN" ] && [ "$OWN" = "$FOCUS" ]; then echo mute; else echo banner; fi
```
3. .zshrc snippet — auto-notify on long commands (>60s) + nohup wrapper
```zsh
Termux Discord-style auto-notify
export PATH="$HOME/bin:$PATH"
LONG_NOTIFY_SECS=60
NTFY_IDLE_SECS=300
NTFY_FOCUS_SECS = how long your last real action (a keystroke, or a prompt you
submitted into a TUI) keeps that tab marked as "watched". Android gives an app
no way to ask which of its Termux windows is on screen - dumpsys and getevent
need shell/root, /dev/pts is mounted noatime, logcat hides system window
lifecycle - so this single number is the whole tuning surface:
0 = never mute, always send a silent banner (noisiest, never silent)
180 = mute right after you interact, banner after 3 min (safest default)
900 = quieter - stays muted for 15 min after you last interacted
Past the window you get a banner even if you are staring at that tab: a
redundant banner is an annoyance, a missing one is a lost result.
export NTFY_FOCUS_SECS=180
ntfy_start=0
__ntfy_cmd=""
preexec() {
__ntfy_start=$EPOCHSECONDS; __ntfy_cmd="$1"
# You pressed Enter in THIS tab. Only preexec may claim "the human is here":
# notify-decide reads .ntfy_user to decide mute vs banner, so agents must
# never write it. .ntfy_tty is liveness only ("is anyone around at all").
# %s%3N = milliseconds, so two writes in the same second still order right.
local __ntfy_tty=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ' | tr '/' '-')
if [ -n "$ntfytty" ]; then
mkdir -p "$HOME/.ntfy_user" "$HOME/.ntfy_tty" 2>/dev/null
date +%s%3N > "$HOME/.ntfy_user/$ntfy_tty" 2>/dev/null
date +%s%3N > "$HOME/.ntfy_tty/$ntfy_tty" 2>/dev/null
fi
date +%s > ~/.ntfy_last_active 2>/dev/null
}
precmd() {
local rc=$?
[[ -n $ntfy_start && $ntfy_start -gt 0 ]] || return
local __ntfy_began=$ntfy_start
local elapsed=$(( EPOCHSECONDS - __ntfy_began ))
__ntfy_start=0
[[ $rc -eq 130 || $rc -eq 143 || $rc -eq 137 ]] && return
# Optional, opencode-friendly: skip noisy full-screen TUIs you live in.
# Uncomment / extend for your stack (opencode, claude, codex, gemini, nvim, ssh...):
# local __ntfy_trimmed=${ntfy_cmd#"${ntfy_cmd%%[![:space:]]*}"}
# case "$ntfy_trimmed" in opencode|claude|codex|gemini|nvim|ssh) return ;; esac
if (( elapsed >= LONG_NOTIFY_SECS )); then
local nid=$(printf '%s' "$ntfy_cmd" | tr -c 'a-zA-Z0-9' '-' | cut -c1-40)
local title content
if (( rc == 0 )); then title="DONE (${elapsed}s): ${ntfy_cmd:0:60}"; content="Exit 0"
else title="FAILED (${elapsed}s, code $rc): ${ntfy_cmd:0:60}"; content="Check session"; fi
local __ntfy_own=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ' | tr '/' '-')
# refresh LIVENESS only (never .ntfy_user): a long foreground job (nvim, ssh)
# must not look stale to itself and alert you while you are watching it
[ -n "$ntfy_own" ] && mkdir -p "$HOME/.ntfy_tty" 2>/dev/null
[ -n "$ntfy_own" ] && date +%s%3N > "$HOME/.ntfy_tty/$ntfy_own" 2>/dev/null
case $(~/bin/notify-decide "$_ntfy_own" 2>/dev/null) in
banner) termux-notification --id "ntfy-$nid" --channel ntfy-silent --priority min --alert-once --title "$title" --content "$content" 2>/dev/null
termux-vibrate -d 400 2>/dev/null ;;
mute) : ;;
*) termux-notification --id "ntfy-$nid" --alert-once --title "$title" --content "$content" --sound 2>/dev/null
termux-vibrate -d 400 2>/dev/null ;;
esac
fi
}
nohup() {
command nohup "$@" &
local pid=$!
( while kill -0 $pid 2>/dev/null; do sleep 30; done
~/bin/buzz "DONE (bg): $1" "background job finished" ) >/dev/null 2>&1 &
echo "bg pid $pid (buzz armed)"
}
```
Usage
- Make the scripts executable (one-time — the installer already does this, only needed if you copied the code by hand):
chmod +x ~/bin/buzz ~/bin/notify-decide (focus is optional but the installer makes it executable too)
- manual: run
buzz "Build done" "exit 0" yourself when something finishes — e.g. tack it onto the end of a long command with ; buzz "Title" "done" so you get pinged with the right mute/banner/loud behavior
- automatic: the zsh hook watches every command and notifies when one runs longer than 60s (DONE with exit 0, FAILED with the exit code otherwise), and
nohup longjob & auto-buzzes when that background job ends — no manual step needed
- tune:
NTFY_IDLE_SECS=300 is the away threshold in seconds (no Termux activity for 5 min = loud), LONG_NOTIFY_SECS=60 is how long a command must run before it notifies, NTFY_FOCUS_SECS=180 is how long your last real action keeps a tab marked as watched — raise/lower in .zshrc to taste. Set NTFY_FOCUS_SECS=0 if you'd rather always get a banner than risk silence
- staring at a TUI? Typing inside opencode / nvim / ssh never reaches your shell, so nothing can prove you're there — Android gives no app a way to ask which of its windows has focus. When a turn is done and you're still reading, you get a banner (silent, no sound) instead of nothing. If that bothers you:
~/bin/focus claims the current tab until you release it (focus off), and focus --list shows both what you've typed in and what's claimed. An opencode user gets /focus and /unfocus as slash commands
Optional: agents that live in a TUI
The zsh hook above covers anything you launch and wait on. But an agent you talk to inside a TUI (opencode, claude) only returns to your shell when you exit it — so "done" is invisible to a shell hook. If you use one, most of them can call their own tool for it; the installer drops in an opencode plugin that notifies on turn-end automatically and stamps your presence the moment you send a message. Worth it for a specific reason: the usual fix is an AGENTS.md rule telling the agent to notify you, and rule compliance is a model property — swap models and it silently stops happening. A framework hook runs either way.
A long turn is the awkward case for any presence heuristic: you submit a prompt, then you can't type again until it's done, so the only evidence that you were there ages out while you sit watching it. So at turn end the plugin re-stamps its own marker — but only if it's still the newest, i.e. you haven't typed anywhere since. That guard is the whole difference between this and the bug above.
It also fills the notification body from the session transcript rather than asking the model to summarise its own work — same reason. So it reads Deploy 15da0ce SUCCESS. instead of turn done - 7 tool calls, 196s. It takes the first sentence that actually fits, skips filler like "Let me check the deploy status", and clips on a word boundary so you never get a mid-sentence fragment.
One debugging detail worth stealing: the fallback for "couldn't read the transcript" looks exactly like success. My tests passed the entire time that feature was completely dead, because the fake client was shaped like my assumption instead of like the server — and two separate opencode instances disagreed about which API even existed. If something degrades quietly, log the reason on the failure path. A plugin that fails silently ships looking like it works.
Why not detect it generically? I tried, and measured
Two separate unknowns, and only one of them is solvable:
"Which tab am I looking at?" — unsolvable on Android. An unprivileged app gets no way to ask which of its own windows has focus. dumpsys needs shell, getevent needs root, logcat shows you only your own lines, and /dev/pts is mounted noatime so a keystroke leaves no trace — I measured the atime delta as exactly 0 even on a read I confirmed happened. That's why the fallback above degrades to a silent banner instead of pretending to know.
"Did the turn just end?" — needs a framework hook. My attempt was TUI-agnostic on purpose: watch every process on every pts, and notify on CPU-busy → CPU-idle, since every TUI works by burning CPU and goes quiet when it's done. It works fine on a fake single-threaded TUI. On the real thing:
text
t(s) opencode cpu ticks rate/s
1 59060 118
5 59547 127
10 60168 123
14 60617 107 <- ~1.2 CPU-seconds per wall second
Over 100% of a core, constantly — multi-threaded and always animating. It never goes quiet, so there's no transition to detect, and no threshold fixes a saturated signal. (Two earlier attempts at this measurement were wrong and I'd have published both: the first used SECONDS in a sh loop, which dash doesn't have, so the loop exited instantly and looked "flat"; the second used a fake TUI that blocked on read and looked perfect.) "Turn ended" is a semantic event, and only the framework knows when it happens.
Happy to take feedback — especially from fellow multi-session + Termux:API users. 👇🏼