feat(omo): coloured tab markers and clickable Ghostty banners

omo-notify now drives three signals from omo's UserPromptSubmit, Stop
and Notification hooks:

- A sound through afplay, so Focus modes cannot mute it
- A Ghostty notification (OSC 777) written to the session's tmux client
  terminal, so clicking it opens the tab it came from. This replaces the
  osascript banner, which opened Script Editor's file picker on click
- omo-status markers in front of the tab title: red error, amber
  waiting for input, green finished, blue working. A tab shows its most
  urgent agent; focusing it clears red, amber and green and selects the
  pane that sent the banner

tmux puts @omo_status in the title and runs omo-status on client focus.
This commit is contained in:
David Ibia
2026-10-06 23:02:55 +01:00
parent d1aeda397a
commit 6fb7007ea9
5 changed files with 171 additions and 30 deletions
+7
View File
@@ -1,5 +1,12 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "~/.local/bin/omo-notify", "timeout": 10 }
]
}
],
"Stop": [
{
"hooks": [
+68 -26
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bash
# macOS notification for omo (senpi) Stop and Notification hooks.
# Reads the hook JSON payload on stdin. Wired up in ~/.omo/agent/hooks.json.
# Ding, tab marker and notification for omo (senpi) hooks: UserPromptSubmit,
# Stop and Notification. Reads the hook JSON payload on stdin. Wired up in
# ~/.omo/agent/hooks.json. The sound plays through afplay, so Focus modes and
# notification permissions cannot mute it; the banner is a silent extra.
# Ghostty posts the banner, so clicking it opens the tab it came from, and
# omo-status then selects the session's pane. omo-status also colours that tab:
# blue working, amber waiting for you, green finished, red error.
# Hooks run with a minimal environment, so make Homebrew tools reachable.
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
@@ -11,42 +16,79 @@ field() { jq -r "$1 // empty" <<<"$payload" 2>/dev/null; }
event=$(field '.hook_event_name')
project=$(basename "$(field '.cwd')")
# Only top-level sessions live under .omo/agent/sessions/. Subagents, team members
# and other background sessions keep transcripts elsewhere (e.g. senpi-task/children).
transcript=$(field '.transcript_path')
[[ -z "$transcript" || "$transcript" == */.omo/agent/sessions/* ]] || exit 0
message=""
sound=""
case "$event" in
UserPromptSubmit)
state=working
;;
Stop)
# Esc/interrupts are your own doing; no need to be told about them.
[[ "$(field '.stopReason')" == "aborted" ]] && exit 0
# Only top-level sessions live under .omo/agent/sessions/. Subagents, team members
# and other background sessions keep transcripts elsewhere (e.g. senpi-task/children).
transcript=$(field '.transcript_path')
[[ -z "$transcript" || "$transcript" == */.omo/agent/sessions/* ]] || exit 0
if [[ "$(field '.stopReason')" == "error" ]]; then
message="Stopped with an error"
else
message="Task finished"
fi
sound="Glass"
case "$(field '.stopReason')" in
# Esc/interrupts are your own doing; no need to be told about them.
aborted) state="clear" ;;
error) state="error" message="Stopped with an error" sound="Glass" ;;
*) state="done" message="Task finished" sound="Glass" ;;
esac
;;
Notification)
# Only alert when a question appears, not when it is answered or times out.
[[ "$(field '.kind')" == "ask-user-asked" ]] || exit 0
message="Needs your input: $(field '.title')"
sound="Ping"
if [[ "$(field '.kind')" == "ask-user-asked" ]]; then
state=input message="Needs your input: $(field '.title')" sound="Ping"
else
# Answered or timed out: the agent carries on.
state=working
fi
;;
*)
exit 0
;;
esac
# Background the ding so the hook returns immediately; redirect output so the
# hook runner is not left waiting on afplay's inherited pipes.
[[ -n "$sound" ]] && afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 &
# Find the tmux pane omo runs in: hooks run detached without TMUX_PANE, so walk
# up the process tree to the first ancestor whose tty is a tmux pane.
# Outside tmux, the first tty found is the Ghostty tab's own terminal.
pane=""
own_tty=""
panes=$(tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null)
pid=$PPID
while [[ -z "$pane" && "${pid:-1}" -gt 1 ]]; do
tty=$(ps -o tty= -p "$pid" | tr -d ' ')
if [[ -n "$tty" && "$tty" != "??" ]]; then
[[ -n "$own_tty" ]] || own_tty="/dev/$tty"
[[ -n "$panes" ]] && pane=$(awk -v t="/dev/$tty" '$1 == t { print $2 }' <<<"$panes")
fi
pid=$(ps -o ppid= -p "$pid" | tr -d ' ')
done
[[ -n "$pane" ]] && "$HOME/.local/bin/omo-status" set "$pane" "$state"
[[ -n "$message" ]] || exit 0
log="${OMO_NOTIFY_LOG:-$HOME/.local/state/omo-notify.log}"
mkdir -p "$(dirname "$log")"
printf '%s %s %s %s [%s %s]\n' "$(date '+%F %T')" "$event" "$project" "$message" \
"$(field '.session_id')" "$(field '.transcript_path')" >>"$log"
printf '%s %s %s %s [%s %s] pane=%s\n' "$(date '+%F %T')" "$event" "$project" "$message" \
"$(field '.session_id')" "$(field '.transcript_path')" "${pane:-none}" >>"$log"
# Pass values as argv so quotes in titles cannot break the AppleScript.
osascript - "omo: ${project:-session}" "$message" "$sound" <<'APPLESCRIPT' >/dev/null 2>&1
on run argv
display notification (item 2 of argv) with title (item 1 of argv) sound name (item 3 of argv)
end run
APPLESCRIPT
# The banner is an OSC 777 notification for Ghostty. In tmux it goes to the tab's
# tmux client terminal, because tmux does not pass it on from a pane, and
# @omo_jump tells omo-status which pane to select when that tab gets focus.
target=$own_tty
if [[ -n "$pane" ]]; then
session=$(tmux display-message -p -t "$pane" '#{session_id}')
target=$(tmux list-clients -t "$session" -F '#{client_activity} #{client_tty}' | sort -rn | awk 'NR == 1 { print $2 }')
tmux set-option -t "$session" @omo_jump "$pane"
fi
# Control characters would end the escape sequence early; ";" separates its fields.
clean() { tr -d '\000-\037\177' <<<"$1" | tr ';' ','; }
if [[ -n "$target" && -w "$target" ]]; then
printf '\033]777;notify;%s;%s\a' "$(clean "omo: ${project:-session}")" "$(clean "$message")" >"$target"
fi
exit 0
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Coloured attention markers on Ghostty tabs for omo sessions. tmux puts the
# session's @omo_status in front of the tab title (set-titles-string in
# ~/.tmux.conf), so each tab shows what its agents need:
# 🔴 stopped with an error 🟡 waiting for your answer
# 🟢 finished 🔵 working
# Each pane running omo keeps its own state in the pane option @omo_state, and
# the tab shows the most urgent one. Focusing the tab clears red, amber and green,
# and selects the pane whose banner you were sent (@omo_jump, set by omo-notify).
#
# Usage: omo-status set <pane> <working|input|done|error|clear>
# omo-status focus <client> (run by the tmux client-focus-in hook)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
icon() {
case $1 in
error) echo "🔴" ;;
input) echo "🟡" ;;
done) echo "🟢" ;;
working) echo "🔵" ;;
esac
}
rank() {
case $1 in
error) echo 4 ;;
input) echo 3 ;;
done) echo 2 ;;
working) echo 1 ;;
*) echo 0 ;;
esac
}
show_most_urgent() {
local session=$1 state best=""
while read -r state; do
if (($(rank "$state") > $(rank "$best"))); then best=$state; fi
done < <(tmux list-panes -s -t "$session" -F '#{@omo_state}')
if [[ -n $best ]]; then
tmux set-option -t "$session" @omo_status "$(icon "$best")"
else
tmux set-option -u -t "$session" @omo_status
fi
}
# You are looking at a pane when its tab has focus and its window is showing.
looking_at() {
local pane=$1 session=$2
[[ $(tmux display-message -p -t "$pane" '#{window_active}') == 1 ]] &&
tmux list-clients -t "$session" -F '#{client_flags}' | grep -q focused
}
case ${1:-} in
set)
pane=${2:?pane id required} state=${3:?state required}
session=$(tmux display-message -p -t "$pane" '#{session_id}' 2>/dev/null) || exit 0
if [[ $state != working && $state != clear ]] && looking_at "$pane" "$session"; then
state=clear
fi
if [[ $state == clear ]]; then
tmux set-option -p -u -t "$pane" @omo_state
else
tmux set-option -p -t "$pane" @omo_state "$state"
fi
show_most_urgent "$session"
;;
focus)
client=${2:?client required}
session=$(tmux list-clients -F '#{client_name} #{session_id}' | awk -v c="$client" '$1 == c { print $2 }')
[[ -n $session ]] || exit 0
jump=$(tmux show-option -qv -t "$session" @omo_jump)
if [[ -n $jump ]]; then
tmux set-option -u -t "$session" @omo_jump
tmux select-window -t "$jump" \; select-pane -t "$jump" 2>/dev/null
fi
while read -r pane state; do
case $state in
input | done | error) tmux set-option -p -u -t "$pane" @omo_state ;;
esac
done < <(tmux list-panes -s -t "$session" -F '#{pane_id} #{@omo_state}')
show_most_urgent "$session"
;;
*)
sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2
exit 2
;;
esac
+4 -1
View File
@@ -64,7 +64,10 @@ bind -r m resize-pane -Z
# Push tmux window/session info to the terminal title
set -g set-titles on
set -g set-titles-string "#{session_name}"
# omo-status puts a coloured marker (@omo_status) in front of the session name;
# focusing the tab clears the attention markers.
set -g set-titles-string "#{?@omo_status,#{@omo_status} ,}#{session_name}"
set-hook -g client-focus-in 'run-shell -b "~/.local/bin/omo-status focus #{q:hook_client}"'
# Set plugin manager path before loading plugins
set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins"
+4 -3
View File
@@ -157,7 +157,7 @@ Key features:
### Development Tools
- **tmux** (`.tmux.conf`): Terminal multiplexer with custom key bindings; sets the terminal title to the session name
- **tmux** (`.tmux.conf`): Terminal multiplexer with custom key bindings; sets the terminal title to the session name, after the omo status marker when there is one
- **Zed** (`.config/zed/keymap.json`): Editor key bindings. Settings stay local because they reference machine-specific agent paths
- **tmux server** (`.tmux-server.conf`): Lightweight tmux config for remote servers
- **Git**: Global gitignore patterns (`.rgignore`, `.gitignore`)
@@ -196,7 +196,7 @@ MCP servers: Playwright, Chrome DevTools, Linear, reoclo, time, ast-grep, headro
#### OmO (`.config/omo/`)
- `omo.jsonc` (linked as `~/.omo/omo.jsonc`): agent and category model routing for OpenCode and the native harness.
- `hooks.json` (linked as `~/.omo/agent/hooks.json`): runs `omo-notify` for a macOS notification when a session stops or needs attention.
- `hooks.json` (linked as `~/.omo/agent/hooks.json`): runs `omo-notify` when you send a prompt, when a session stops and when it asks you something, for the sound, the notification and the coloured tab marker.
- `prompts/` (linked as `~/.omo/agent/prompts`): slash commands for `owt` workspaces.
- `/linear-sync [notes]` posts a progress comment on the workspace's Linear issues and sets them to In Progress, or In Review once a PR is open.
- `/owt-done [notes]` finishes a workspace after its PR merges: it comments on each issue and moves it to Done, then runs `owt done --detach`. It asks before discarding uncommitted or unmerged work.
@@ -247,7 +247,8 @@ Custom scripts and binaries are stored in `.local/bin/`:
- `claude-tmux`: Claude integration with tmux
- `cldir`: Change directory with Claude context
- `owt`: Git worktree workspaces for coding agents (see [Agent workspaces](#agent-workspaces-owt))
- `omo-notify`: macOS notification for OmO's Stop and Notification hooks
- `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane
- `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: 🔴 stopped with an error, 🟡 waiting for your answer, 🟢 finished, 🔵 working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green
- `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers)
The `links` step of `setup-mac.sh` links each script into `~/.local/bin`, which `.zshrc` puts on your PATH. `pbcopy` and `pbpaste` are OSC 52 clipboard shims for headless servers, so they are not linked on macOS.