#!/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; } # omo's UserPromptSubmit payload names the event only in "event". event=$(field '.hook_event_name // .event') 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. # omo-remote names the pane itself (OMO_NOTIFY_PANE): for a session on a remote # server it is the local pane running mosh or ssh. pane="${OMO_NOTIFY_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