Files
absolute-dotfiles/.local/bin/omo-notify
T
David Ibia 6fb7007ea9 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.
2026-10-06 23:02:55 +01:00

95 lines
3.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# 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"
payload=$(cat)
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)
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)
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] pane=%s\n' "$(date '+%F %T')" "$event" "$project" "$message" \
"$(field '.session_id')" "$(field '.transcript_path')" "${pane:-none}" >>"$log"
# 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