diff --git a/.config/omo/hooks.json b/.config/omo/hooks.json index f93dc3e..fa62164 100644 --- a/.config/omo/hooks.json +++ b/.config/omo/hooks.json @@ -1,5 +1,12 @@ { "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "~/.local/bin/omo-notify", "timeout": 10 } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/.local/bin/omo-notify b/.local/bin/omo-notify index 0b2526f..605ad2b 100755 --- a/.local/bin/omo-notify +++ b/.local/bin/omo-notify @@ -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 diff --git a/.local/bin/omo-status b/.local/bin/omo-status new file mode 100755 index 0000000..b385a92 --- /dev/null +++ b/.local/bin/omo-status @@ -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 +# omo-status focus (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 diff --git a/.tmux.conf b/.tmux.conf index e0c108e..c65dbe4 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -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" diff --git a/README.md b/README.md index deb08df..913cb3f 100644 --- a/README.md +++ b/README.md @@ -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.