diff --git a/.local/bin/omo-notify b/.local/bin/omo-notify index 0dda6a6..b6e3ee3 100755 --- a/.local/bin/omo-notify +++ b/.local/bin/omo-notify @@ -56,7 +56,9 @@ esac # 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="" +# 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 diff --git a/.local/bin/omo-remote b/.local/bin/omo-remote new file mode 100755 index 0000000..3c2f42e --- /dev/null +++ b/.local/bin/omo-remote @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Ding, tab marker and notification for omo sessions on a remote server +# (flowstate-remote), the same as omo-notify gives local sessions. Two paths, +# so each event arrives once: +# +# Title: the server's omo-notify sets the terminal title of every tmux client +# attached to the session to "omo:::[:]". +# mosh and ssh carry titles to this Mac (mosh drops the OSC 777 banner), where +# it becomes the title of the local pane running mosh or ssh. tmux's +# pane-title-changed hook (~/.tmux.conf) runs `omo-remote title `, which +# hands the event to omo-notify for that pane. +# +# ntfy: the server also posts each finish, error and question to ntfy, tagged +# "omo", plus "attached" when the title reached a terminal. The launchd agent +# runs `omo-remote subscribe` and dings for omo pushes without "attached": +# sessions that no terminal is showing. +# +# Setup: put NTFY_URL, NTFY_TOPIC and NTFY_TOKEN in ~/.config/ntfy/omo-remote.env +# (chmod 600), then run `omo-remote install` once. +# +# Usage: omo-remote title (run by the tmux pane-title-changed hook) +# omo-remote subscribe (run by the launchd agent) +# omo-remote message (run by ntfy for each push; reads $NTFY_*) +# omo-remote install (install and start the launchd agent) + +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" + +label="local.omo-remote" +conf="${OMO_REMOTE_CONF:-$HOME/.config/ntfy/omo-remote.env}" +log="${OMO_REMOTE_LOG:-$HOME/.local/state/omo-remote.log}" +# An older event happened while you were away (the Mac slept or mosh was +# reconnecting): mark the tab, but skip the ding and the banner. +fresh=120 + +logline() { + mkdir -p "$(dirname "$log")" + printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$log" +} + +case ${1:-} in + title) + pane=${2:?pane id required} + title=$(tmux display-message -p -t "$pane" '#{pane_title}' 2>/dev/null) || exit 0 + [[ $title == *omo:* ]] || exit 0 + event=${title#*omo:} # mosh puts "[mosh] " in front + # Act once per event, however often the title is set again. + [[ $(tmux show-option -pqv -t "$pane" @omo_remote_event) == "$event" ]] && exit 0 + tmux set-option -p -t "$pane" @omo_remote_event "$event" + + IFS=: read -r state when project question <<<"$event" + [[ $when =~ ^[0-9]+$ ]] || exit 0 + case $state in + working) payload='{"event":"UserPromptSubmit"}' ;; + done) payload='{"hook_event_name":"Stop","stopReason":"stop"}' ;; + error) payload='{"hook_event_name":"Stop","stopReason":"error"}' ;; + clear) payload='{"hook_event_name":"Stop","stopReason":"aborted"}' ;; + input) payload=$(jq -cn --arg q "$question" '{hook_event_name:"Notification",kind:"ask-user-asked",title:$q}') ;; + *) exit 0 ;; + esac + if (($(date +%s) - when > fresh)); then + exec "$HOME/.local/bin/omo-status" set "$pane" "$state" + fi + payload=$(jq -c --arg cwd "${project:-remote}" '. + {cwd: $cwd}' <<<"$payload") + OMO_NOTIFY_PANE=$pane exec "$HOME/.local/bin/omo-notify" <<<"$payload" + ;; + subscribe) + if [[ ! -r $conf ]]; then + echo "omo-remote: $conf is missing" >&2 + exit 1 + fi + set -a + # shellcheck disable=SC1090 + . "$conf" + set +a + : "${NTFY_URL:?NTFY_URL is not set in $conf}" "${NTFY_TOPIC:?NTFY_TOPIC is not set in $conf}" + # ntfy reads the token from $NTFY_TOKEN, which keeps it out of `ps`. + exec ntfy subscribe "${NTFY_URL%/}/$NTFY_TOPIC" "$(printf '%q' "$0") message" + ;; + message) + tags=",${NTFY_TAGS:-}," + # Claude Code posts to the same topic; "attached" means the title path has it. + [[ $tags == *,omo,* && $tags != *,attached,* ]] || exit 0 + (($(date +%s) - ${NTFY_TIME:-0} <= fresh)) || exit 0 + sound=Glass + [[ $tags == *,question,* ]] && sound=Ping + afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 & + # No pane shows this session, so Ghostty posts the banner from the tab you + # used last. + target=$(tmux list-clients -F '#{client_activity} #{client_tty}' 2>/dev/null | sort -rn | awk 'NR == 1 { print $2 }') + clean() { tr -d '\000-\037\177' <<<"$1" | tr ';' ','; } + if [[ -n $target && -w $target ]]; then + printf '\033]777;notify;%s;%s\a' "$(clean "omo: ${NTFY_TITLE:-remote session}")" \ + "$(clean "$(head -n 1 <<<"${NTFY_MESSAGE:-}")")" >"$target" + fi + logline "ntfy ${NTFY_TITLE:-} target=${target:-none}" + ;; + install) + if [[ ! -r $conf ]]; then + echo "omo-remote: put NTFY_URL, NTFY_TOPIC and NTFY_TOKEN in $conf first" >&2 + exit 1 + fi + command -v ntfy >/dev/null || { + echo "omo-remote: install the ntfy CLI first (brew install ntfy)" >&2 + exit 1 + } + plist="$HOME/Library/LaunchAgents/$label.plist" + mkdir -p "$(dirname "$log")" "$(dirname "$plist")" + cat >"$plist" < + + + + Label + $label + ProgramArguments + + $HOME/.local/bin/omo-remote + subscribe + + RunAtLoad + + KeepAlive + + ThrottleInterval + 30 + StandardOutPath + $log + StandardErrorPath + $log + + +PLIST + launchctl bootout "gui/$(id -u)/$label" 2>/dev/null + launchctl bootstrap "gui/$(id -u)" "$plist" && echo "omo-remote: listening for ntfy pushes ($plist)" + ;; + *) + sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2 + exit 2 + ;; +esac diff --git a/.tmux.conf b/.tmux.conf index e54fd63..ed3ced7 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -68,6 +68,9 @@ set -g set-titles on # 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}"' +# omo on a remote server reports its state in the title of a mosh or ssh pane +# ("omo::..."); omo-remote turns that into the same ding, marker and banner. +set-hook -g pane-title-changed 'if -F "#{&&:#{m:*omo:*,#{pane_title}},#{||:#{==:#{pane_current_command},mosh-client},#{==:#{pane_current_command},ssh}}}" "run-shell -b \"~/.local/bin/omo-remote title #{hook_pane}\""' # Set plugin manager path before loading plugins set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins" diff --git a/Brewfile b/Brewfile index ab78b14..c4bbc19 100644 --- a/Brewfile +++ b/Brewfile @@ -25,6 +25,7 @@ brew "tmux" brew "zellij" brew "yazi" brew "mosh" +brew "ntfy" brew "openssh" brew "gu1llaum-3/sshm/sshm", trusted: true diff --git a/README.md b/README.md index 3ba8196..afc9436 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ Custom scripts and binaries are stored in `.local/bin/`: - `tmux-open-path`: Backs tmux's Ctrl+click. Takes the path under the mouse, strips `:line:col`, `#L10`, `@` and `file://`, resolves it against the pane's directory and then its git root, and routes it by type: markdown to Markdown Preview (`cask "markdown-preview"` in `Brewfile.apps`), images, PDFs and directories to yazi, the rest to nvim - `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, and undoes any title you gave the tab by hand so it always shows its session name +- `omo-remote`: The same sound, tab marker and notification for omo sessions on a remote server (flowstate-remote). The server's `omo-notify` (kept on the server in `~/.local/bin`, not in this repository) writes the session's state into its terminal title as `omo::...`. mosh drops notifications but carries titles, and so does ssh, so tmux's `pane-title-changed` hook passes the event to `omo-notify` for the pane running mosh or ssh. A session that no terminal is showing reaches you through ntfy instead: `omo-remote install` starts a launchd agent that subscribes to the topic and plays the sound and posts the notification for omo pushes. The server tags a push `attached` when the title already reached a terminal, and the agent skips those so nothing arrives twice. The agent reads `NTFY_URL`, `NTFY_TOPIC` and `NTFY_TOKEN` from `~/.config/ntfy/omo-remote.env` (chmod 600, not in this repository) and needs `brew "ntfy"` - `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.