diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 58e42a1bb..0942e876b 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -154,6 +154,17 @@ marker, carrying pre-read status summaries and a recommended action. The single-line format makes the submission unambiguous across harnesses, and the marker lets firstmate distinguish it from a real captain message. +On a **confirmed** flush (a successful inject, never a routine self-handled +wake), `escalate_flush` also fires a best-effort native OS notification through +`bin/fm-notify.sh "Firstmate" "" --focus`, so +the captain is pinged out of band while away from the pane. This is the issue +#106 notifier hook: the summary carries no task ids or internal vocabulary +(it can surface on a lock screen), the `FM_NOTIFY` toggle silences it +(`FM_NOTIFY=off`), and a missing or failing notifier never affects escalation +delivery. `FM_NOTIFY_CMD` overrides the notifier path for tests; the hook checks +`fm_notify_enabled` before running anything, so `FM_NOTIFY=off` silences even a +custom `FM_NOTIFY_CMD` that would not consult the toggle itself. + ## Injection hardening - **Single-line digest** - embedded newlines are collapsed to a literal diff --git a/AGENTS.md b/AGENTS.md index 32fb84d7b..689c94994 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,9 +93,10 @@ state/ volatile runtime signals; gitignored .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks - .hash-* .count-* .stale-* .stale-since-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch + .hash-* .count-* .stale-* .stale-since-* .seen-* .hb-surfaced-* .notify-seen-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); fm-guard.sh reads it + .fm-tmux-pane firstmate's own recorded tmux pane id; written by fm-notify.sh install, read by fm-focus.sh so a notification click lands on the exact pane (section 8) .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch .no-mistakes/ local validation state and evidence; gitignored ``` @@ -563,6 +564,38 @@ Inline facts that must survive without a loaded skill: - Afk never changes approval authority; PR merges, ask-user findings, destructive actions, irreversible actions, and security-sensitive choices still require the same approval they required before. - Bias ambiguous cases toward exit because a present captain beats token savings and a false exit is self-correcting. +### Out-of-band notifier + +`bin/fm-notify.sh` fires a native OS notification so the captain gets pinged off the pane the moment captain-relevant work needs them. +It is the first backend of the pluggable notifier in issue #106, and it fires in BOTH supervision modes. +In normal operation the always-on watcher (`bin/fm-watch.sh`) fires it from its signal path the moment a signal wake carries a captain-relevant verb (`done`/`needs-decision`/`blocked`/`failed`/`merged`/`checks green`/`PR ready`/`ready in branch`), never on `working:`/turn-end/heartbeat wakes. +In away mode the sub-supervisor calls it only when it actually delivers a captain-relevant escalation digest, never on routine self-handled wakes (and the watcher hook stands down while afk, so the captain is never double-pinged). +Both paths share the same filtering and emit the same id-free count-and-class summary (a "needs your decision" vs "ready for review" class), dedupe per task by content so the same status is never toasted twice, and are fully error-isolated and backgrounded so a missing, failing, or slow notifier never changes supervision. +Usage is `bin/fm-notify.sh "" "<message>" [--focus|--no-focus] [--open <url>]`; it detects the platform and routes to the right native notifier, is dependency-light and non-blocking, and exits quietly when no notifier is reachable. +Every notification pops, sounds, and persists by default, and click-to-focus is on by default - a bare `bin/fm-notify.sh "T" "M"`, the daemon hook, and the watcher hook all produce the same full notification (the captain hit a silent drop when a toast fired without the focus action, so focus-on is now the default; `--no-focus` exists only for tests). +On WSL/Windows the toast is a raw WinRT toast via `powershell.exe` (no modules): `scenario="reminder"` (ToastGeneric) so it pops as a banner and stays until acted on instead of dropping silently into the notification center, with an explicit `<audio>` so it always sounds (a quiet-dropped toast otherwise makes no sound), and a "Go to firstmate" click-to-focus action. +The two platforms below mirror that behavior as closely as each robustly allows; they were implemented and unit-tested for dispatch and argument-building but NOT end-to-end verified, since firstmate develops on WSL. +On macOS the notifier uses `terminal-notifier` when present (a persistent alert - which requires the captain to set terminal-notifier's notification style to "Alerts" in System Settings - plus a sound and an `-execute` click-to-focus that runs `bin/fm-focus.sh`); without it, it falls back to `osascript display notification` WITH a sound, which sounds but cannot persist or click-to-focus. +On Linux the notifier uses `notify-send --urgency=critical` for persistence, a best-effort sound via `canberra-gtk-play`/`paplay`/`aplay` when one is installed (guarded, never fatal), and `notify-send --action` click-to-focus where the desktop's notification daemon supports actions (GNOME-style libnotify does; many do not), degrading to persistent+sound where it does not. +What each non-Windows path delivers depends on installed tools: macOS click-to-focus needs `terminal-notifier`, Linux click-to-focus needs an action-capable `notify-send` daemon plus `wmctrl` or `xdotool` to raise the window, and a sound needs a player; the tmux pane select still lands even when no window-raise tool is present. +The `FM_NOTIFY` env toggle gates it: it is on by default, and `FM_NOTIFY=off` (also `0`/`false`/`no`/`disabled`) silences every notification. +That gate is enforced wherever the notifier is fired so a custom notifier cannot become a bypass: the script self-gates, and both the daemon's escalation hook (`notify_escalation`) and the watcher's signal hook (`notify_signal_statuses`) check `fm_notify_enabled` before running anything, so `FM_NOTIFY=off` silences the toast even when `FM_NOTIFY_CMD` points at an override that would not consult the toggle itself. + +Focus is click-to-focus, never auto-focus (the notification never steals the foreground on its own, which was rejected as invasive); it is on by default and `--no-focus` opts out (tests only). +The click action is wired per platform: on Windows it carries a "Go to firstmate" action (and a body launch) wired to the registered `firstmate:` URL protocol; on macOS it is `terminal-notifier -execute`; on Linux it is `notify-send --action`. +All three run `bin/fm-focus.sh`, which raises the host terminal (Windows Terminal via `powershell.exe`, the macOS Terminal/iTerm via `osascript`, or an X window via `wmctrl`/`xdotool`) and then selects the firstmate tmux window AND pane. +That window+pane resolution is shared across all platforms in `bin/fm-focus-lib.sh` (so the notifier and the handler cannot drift): it is resolved dynamically at click time with nothing hardcoded - no session name, window index, or pane literal - so it survives a session rename, a window move, and a firstmate restart. +The primary signal is the pane id firstmate records to `state/.fm-tmux-pane` (under the firstmate home, derived from `$FM_HOME`); if that pane is gone, `fm-focus.sh` falls back to the claude pane whose cwd is the firstmate home. +On Windows the protocol points at a hidden `wscript` VBS launcher (window style 0, so no console flashes) that runs `bin/fm-focus.sh`; run `bin/fm-notify.sh install` once to arm it: it records the pane id and, on WSL/Windows only, registers the `firstmate:` protocol under `HKCU` and writes the launcher pointing at the installed `bin/fm-focus.sh`; it is idempotent (safe to re-run) and a clean no-op off WSL/Windows (macOS/Linux carry the focus command inline on each notification, so they need no install step). +Focusing a Windows Terminal tab by session is out of scope (unsupported by Windows Terminal; window+pane focus is the captain's chosen design). + +When a done-state escalation carries a PR/MR URL, the notification also gets a second "Open PR" button via `--open <url>`, so the captain can jump straight to the PR. +Both supervision hooks add it automatically: when a captain-relevant `done:` status carries a PR URL (a GitHub `/pull/<n>` or GitLab `/merge_requests/<n>`) and the task's project is not `local-only` (read from the task's `mode=` in `state/<id>.meta`), the watcher and the daemon pass `--open <url>`; `local-only` tasks have no PR and get no button, and the URL rides only the flag so the id-free summary text is unchanged. +The button is reserved for review/done-state notifications: a wake classified as a decision (any buffered `needs-decision`/`blocked`/`failed` item wins) carries no `--open` even when a `done:` PR line sits alongside it, so the action stays consistent with the notification class. +On WSL/Windows it is a real second toast button (`<action content="Open PR" activationType="protocol" arguments="<url>"/>`, the URL XML-attribute-escaped so it cannot break the toast markup) that opens the default browser; on macOS it is best-effort via `terminal-notifier -open <url>` (the single click action, so it replaces the focus `-execute` when a URL is present); on Linux it is best-effort via a second `notify-send --action` that runs `xdg-open <url>` where the daemon supports actions. + +The script keeps a marked seam for later out-of-band backends (a phone push via ntfy or Pushover for the captain's iPhone), so adding one does not touch the native path. + ### Stuck-crewmate recovery On `stale`, looping, repeated confusion, an answered-by-brief question, an unresponsive pane, or a failed steer, load `stuck-crewmate-recovery`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e1675f63..0ba8565c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,8 +61,10 @@ shellcheck bin/*.sh tests/*.sh # lint the toolbelt and behavior tests for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior tests, matching CI and no-mistakes commands.test tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, duplicate-collapse, and drain liveness guard tests tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, and guard-warning tests -tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale wedge threshold, heartbeat backstop, and afk one-shot coherence -tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests +tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale wedge threshold, heartbeat backstop, afk one-shot coherence, the PR-URL/local-only classifier helpers, and the normal-mode outbound notifier hook (captain-relevant fire, FM_NOTIFY gate, content dedup, error isolation, afk skip, Open-PR --open wiring) +tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, fm-send submit, escalation notifier-hook (including the done-state Open-PR --open pass), and FM_NOTIFY=off-with-FM_NOTIFY_CMD gate tests +tests/fm-notify.test.sh # out-of-band notifier: platform detection, FM_NOTIFY=off short-circuit, the always-on persistent+sounding+click-to-focus Windows toast (bare-call default and --no-focus opt-out), the --open "Open PR" action (Windows toast button + XML-escape, macOS -open, Linux --action), macOS terminal-notifier (sound + -execute) and osascript-with-sound fallback, Linux critical + --action click-to-focus, pane-id recording, firstmate: protocol install, and base64 injection-safety +tests/fm-focus.test.sh # click-to-focus handler: dynamic window+pane resolution in a non-firstmate session and the cwd-of-home fallback, the WSL launcher's positional-$1 home taking precedence over FM_HOME (an env wrapper does not survive WScript.Shell.Run), plus the per-platform terminal raise on macOS (osascript) and Linux (wmctrl/xdotool) with graceful degrade, all with host tools stubbed tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence diff --git a/README.md b/README.md index 8464926a1..057abe599 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ It routes each request to a crewmate in its own tmux window and git worktree, su Persistent secondmate homes are linked firstmate worktrees; startup syncs live ones and secondmate launch syncs the target home to the primary default-branch commit without fetching from origin when it is safe. When a routed request goes to a secondmate, firstmate marks it so the answer returns through status or a document pointer; direct typing into that secondmate window stays conversational. A presence-gated sub-supervisor (`/afk`) can self-handle routine events and batch only what matters while you step away. +Whether you are at the pane or away, firstmate also pings you out of band with a native OS notification the moment a real decision needs you. An opt-in X mode can also use the watcher check path to answer your public `@myfirstmate` mentions and act on normal reversible mention requests from the current fleet state, with `FMX_DRY_RUN` available to test the poll -> compose -> would-post loop without publishing. The relay routes only the owner's own mentions to that owner's firstmate home; parent-thread context may still include other public accounts. The token is standing authorization for those autonomous replies and eligible lifecycle actions; destructive, irreversible, or security-sensitive asks are flagged for trusted-channel confirmation instead of being executed from a public mention. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index d1c5d943e..f43ae5930 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -56,6 +56,33 @@ window_to_task() { t="${w##*:}"; t="${t#fm-}"; printf '%s' "$t" } +# Extract the first GitHub PR / GitLab MR URL from a status line, or print nothing. +# A done-state status that carries such a URL (e.g. "done: PR +# https://github.com/o/r/pull/7 checks green") lets the out-of-band notifier offer +# an "Open PR" button. The match is one whitespace-free token ending in +# /pull/<n> or /merge_requests/<n>, so trailing punctuation in the status line is +# excluded. Pure; the caller decides when an Open-PR button is warranted. +fm_status_pr_url() { # <status-line> + printf '%s' "$1" | grep -oiE 'https?://[^[:space:]]+/(pull|merge_requests)/[0-9]+' | head -1 +} + +# Read the delivery mode= a task recorded in its meta, or print nothing when the +# meta is absent or carries no mode. Pure file read; the state dir and task id +# locate state/<task>.meta. +fm_task_mode() { # <state-dir> <task> + local meta="$1/$2.meta" + [ -e "$meta" ] || return 0 + sed -n 's/^mode=//p' "$meta" 2>/dev/null | tail -1 +} + +# 0 (true) when a task's project is local-only, 1 otherwise. A local-only task has +# no PR, so the notifier skips the "Open PR" button for it; an unknown/absent mode +# is treated as NOT local-only (a PR URL would not appear for a local-only task +# anyway). +fm_task_is_local_only() { # <state-dir> <task> + [ "$(fm_task_mode "$1" "$2")" = local-only ] +} + # 0 (actionable) if ANY status file listed in a "signal:" wake carries a # captain-relevant last line; 1 otherwise. Pass the space-separated file list that # follows the "signal:" prefix. Non-.status arguments (e.g. .turn-ended markers, diff --git a/bin/fm-focus-lib.sh b/bin/fm-focus-lib.sh new file mode 100644 index 000000000..20e8a48b8 --- /dev/null +++ b/bin/fm-focus-lib.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# fm-focus-lib.sh - shared, platform-agnostic helpers for the out-of-band +# notifier (bin/fm-notify.sh) and its click-to-focus handler (bin/fm-focus.sh). +# +# Two things live here so they have ONE definition instead of drifting copies: +# 1. Platform detection (wsl|windows|macos|linux|unknown), used by the notifier +# to pick a backend and by the focus handler to pick how to raise the host +# terminal. +# 2. The dynamic tmux pane resolution + selection that lands a click on the +# EXACT firstmate pane. This is identical on every OS - only the terminal +# raise differs - so all of fm-focus.sh's per-platform paths share it. +# +# Sourced, not executed. Idempotent: a double-source is a no-op. +# +# Internal/testing knobs (not the public contract): +# FM_NOTIFY_UNAME override `uname -s` for platform-detection tests. +# FM_NOTIFY_PROC_VERSION path read instead of /proc/version (WSL detection). + +[ -n "${FM_FOCUS_LIB_SOURCED:-}" ] && return 0 +FM_FOCUS_LIB_SOURCED=1 + +_uname_s() { printf '%s' "${FM_NOTIFY_UNAME:-$(uname -s 2>/dev/null)}"; } + +# fm_detect_platform: echo one of wsl|windows|macos|linux|unknown. WSL must be +# decided before plain Linux because a WSL kernel still reports `uname -s`=Linux; +# the discriminator is the "microsoft" marker in /proc/version. +fm_detect_platform() { + local u proc + u=$(_uname_s) + proc="${FM_NOTIFY_PROC_VERSION:-/proc/version}" + case "$u" in + Darwin) printf 'macos\n'; return 0 ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT|Windows) printf 'windows\n'; return 0 ;; + esac + if grep -qi microsoft "$proc" 2>/dev/null; then + printf 'wsl\n'; return 0 + fi + case "$u" in + Linux) printf 'linux\n'; return 0 ;; + esac + if [ "${OS:-}" = "Windows_NT" ]; then printf 'windows\n'; return 0; fi + printf 'unknown\n' +} + +# fm_focus_resolve_pane <pane_file> <home>: echo the firstmate tmux pane id, +# resolved DYNAMICALLY with nothing hardcoded - no session name, window index, or +# pane literal - so it survives a session rename, a window move, and a restart. +# The recorded pane id is the primary signal; if that pane is gone, it falls back +# to the claude pane whose working directory is the firstmate home. Echoes empty +# when neither resolves (the caller then does nothing, never a wrong pane). +fm_focus_resolve_pane() { + local pane_file=$1 home=$2 pane="" + [ -r "$pane_file" ] && pane=$(cat "$pane_file" 2>/dev/null) + if [ -z "$pane" ] || ! tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$pane"; then + # Tab-delimit so a home path containing spaces still compares exactly. + pane=$(tmux list-panes -a -F $'#{pane_id}\t#{pane_current_command}\t#{pane_current_path}' 2>/dev/null \ + | awk -F '\t' -v home="$home" '$2=="claude" && $3==home {print $1; exit}') + fi + printf '%s' "$pane" +} + +# fm_focus_select_tmux <pane>: select the pane's CURRENT session:window and the +# pane itself, resolved now - so a renamed session or moved window still lands on +# the right place. A clean no-op when the pane could not be resolved. +fm_focus_select_tmux() { + local pane=$1 win + [ -n "$pane" ] || return 0 + win=$(tmux display-message -p -t "$pane" '#{session_name}:#{window_index}' 2>/dev/null) + [ -n "$win" ] && tmux select-window -t "$win" >/dev/null 2>&1 + tmux select-pane -t "$pane" >/dev/null 2>&1 +} diff --git a/bin/fm-focus.sh b/bin/fm-focus.sh new file mode 100755 index 000000000..2c17b9eb0 --- /dev/null +++ b/bin/fm-focus.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# fm-focus.sh - raise the host terminal and select the firstmate tmux window+pane. +# +# Invoked when the captain CLICKS a notification's "Go to firstmate" action: +# - Windows/WSL -> the registered "firstmate:" URL protocol -> a hidden VBS +# launcher -> here (in the firstmate distro). +# - macOS -> terminal-notifier's -execute runs this directly. +# - Linux -> notify-send's --action handler runs this directly. +# Click-to-focus, by the captain's choice: nothing here runs unless the captain +# clicks - the toast never steals the foreground on its own. +# +# Everything tmux-side is resolved at click time; NOTHING is hardcoded - not the +# session name, the window index, nor the pane id - so it survives a session +# rename, a window move, and a firstmate restart. The pane id firstmate records +# live is the primary signal; if that pane is gone, it falls back to the claude +# pane whose working directory is the firstmate home. That dynamic resolution is +# shared with the notifier via bin/fm-focus-lib.sh (fm_focus_resolve_pane / +# fm_focus_select_tmux), so the Windows, macOS, and Linux paths cannot drift - +# only the terminal-raise step below differs per platform. +# +# Optional overrides (best-effort terminal raise; the tmux select always runs): +# FM_FOCUS_MACOS_TERM macOS terminal app to activate (default: detect +# iTerm2/Terminal among running processes). +# FM_FOCUS_LINUX_WINDOW Linux window title/name to raise via wmctrl/xdotool. +set -u + +_self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-focus-lib.sh +. "$_self_dir/fm-focus-lib.sh" + +# Resolve the firstmate home (and the pane-id file the notifier records under it) +# from the launcher arg or environment, else from this script's own install +# location (bin/fm-focus.sh -> repo root), matching bin/fm-notify.sh. A +# protocol/-execute click invokes this in a fresh shell, so the self-derived +# default is what makes it work with no FM_HOME in the environment; for a home +# that is not the repo root the notifier supplies it (see below). Never an +# absolute literal. +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$_self_dir/.." && pwd)}" +# The WSL launcher passes the home as $1 (an argument survives WScript.Shell.Run; +# an `env FM_HOME=...` wrapper does not). macOS/Linux backends still pass it via the +# FM_HOME env. Precedence: launcher arg, then env, then self-derived repo root. +FM_HOME="${1:-${FM_HOME:-$FM_ROOT}}" +pane_file="${FM_TMUX_PANE_FILE:-$FM_HOME/state/.fm-tmux-pane}" + +# 1) Raise the host terminal window. Hidden/best-effort and NEVER fatal on every +# platform - if the raise tool is missing, the tmux select below still lands on +# the right pane so the captain finds it active when they switch to the term. +_fm_focus_raise_macos() { + command -v osascript >/dev/null 2>&1 || return 0 + if [ -n "${FM_FOCUS_MACOS_TERM:-}" ]; then + osascript -e "tell application \"$FM_FOCUS_MACOS_TERM\" to activate" >/dev/null 2>&1 + return 0 + fi + # Activate whichever supported terminal is actually running (never launch one). + osascript \ + -e 'tell application "System Events" to set running_apps to name of every process' \ + -e 'if running_apps contains "iTerm2" then' \ + -e ' tell application "iTerm" to activate' \ + -e 'else if running_apps contains "Terminal" then' \ + -e ' tell application "Terminal" to activate' \ + -e 'end if' >/dev/null 2>&1 +} + +_fm_focus_raise_linux() { + local target="${FM_FOCUS_LINUX_WINDOW:-}" wid cls + if [ -n "$target" ] && command -v wmctrl >/dev/null 2>&1; then + wmctrl -a "$target" >/dev/null 2>&1 && return 0 + fi + if command -v xdotool >/dev/null 2>&1; then + if [ -n "$target" ]; then + xdotool search --name "$target" windowactivate >/dev/null 2>&1 && return 0 + fi + # Best-effort: activate a known terminal-emulator window by class. + for cls in gnome-terminal konsole Alacritty kitty org.wezfurlong.wezterm \ + xfce4-terminal xterm; do + wid=$(xdotool search --class "$cls" 2>/dev/null | head -n1) + [ -n "$wid" ] && { xdotool windowactivate "$wid" >/dev/null 2>&1; return 0; } + done + fi + return 0 +} + +case "$(fm_detect_platform)" in + wsl|windows) + # Raise Windows Terminal via hidden PowerShell so no console flashes. The + # single-quoted body is PowerShell, not shell - the $-expressions are + # evaluated by PowerShell. + # shellcheck disable=SC2016 + powershell.exe -NoProfile -WindowStyle Hidden -Command '$p=(Get-Process WindowsTerminal -ErrorAction SilentlyContinue | Select-Object -First 1).Id; if($p){$null=(New-Object -ComObject WScript.Shell).AppActivate($p)}' >/dev/null 2>&1 + ;; + macos) _fm_focus_raise_macos ;; + linux) _fm_focus_raise_linux ;; +esac + +# 2) + 3) Resolve the firstmate tmux pane dynamically, then select its CURRENT +# session:window and the pane itself - shared with the notifier so every +# platform behaves identically here. +pane=$(fm_focus_resolve_pane "$pane_file" "$FM_HOME") +fm_focus_select_tmux "$pane" +exit 0 diff --git a/bin/fm-notify.sh b/bin/fm-notify.sh new file mode 100755 index 000000000..0fa959f54 --- /dev/null +++ b/bin/fm-notify.sh @@ -0,0 +1,544 @@ +#!/usr/bin/env bash +# fm-notify.sh - firstmate's native out-of-band notifier (firstmate issue #106). +# +# Fires a native OS notification so the captain gets pinged out of band the moment +# firstmate escalates a decision while they are away from the pane. This is the +# FIRST backend of the pluggable notifier from issue #106; the pluggable seam for +# a later remote-push backend (a phone push via ntfy or Pushover) is marked below. +# +# Usage: fm-notify.sh "<title>" "<message>" [--focus|--no-focus] [--open <url>] +# fm-notify.sh install # arm click-to-focus (WSL only) +# --focus (DEFAULT) make the toast carry a "Go to firstmate" action that +# focuses the host terminal and the firstmate pane WHEN THE CAPTAIN +# CLICKS it. Click-to-focus, never auto-focus - see below. +# --no-focus opt out of the click-to-focus action (used by tests; the default +# is always focus-on so a bare call still gets the full toast). +# --open <url> add a SECOND "Open PR" action that opens <url> in the default +# browser when clicked. Used for done-state escalations that carry a +# PR/MR URL, so the captain can jump straight to the PR. Windows gets +# a real second toast button (activationType="protocol"); macOS and +# Linux are best-effort (terminal-notifier -open / notify-send +# --action). Omitted -> no extra button (unchanged behavior). +# +# Contract: dependency-free beyond the platform's own tools, fast, and +# non-blocking. The notifier never hangs the caller and never fails it: if no +# backend is reachable on this platform it exits 0 quietly, and the actual +# dispatch is backgrounded so a slow host call (PowerShell startup) cannot stall +# whatever called it. +# +# Toggle: FM_NOTIFY (default on). FM_NOTIFY=off (also 0/false/no/disabled) +# silences every notification. +# +# ALWAYS-ON PERSISTENT, SOUNDING, CLICK-TO-FOCUS toast (the captain's chosen +# design). Every toast pops as a banner, plays a sound, and stays until acted on, +# instead of dropping silently into the notification center - the failure the +# captain hit when a toast fired without the focus action. Focus is ON BY DEFAULT +# for exactly this reason; a bare `fm-notify.sh "T" "M"`, the daemon hook, and the +# watcher hook all produce the same full toast. (--no-focus exists only so tests +# can exercise the opt-out path.) +# +# On WSL/Windows the toast is scenario="reminder" (ToastGeneric) so it persists, +# carries an explicit <audio> so it always sounds (a quiet-dropped toast plays +# nothing), and - by default - a "Go to firstmate" action wired to the registered +# Windows "firstmate:" URL protocol. Auto-focus was rejected as invasive: the +# toast NEVER steals the foreground on its own; only a CLICK fires the protocol +# -> a hidden VBS launcher -> bin/fm-focus.sh, which raises the host terminal and +# selects the firstmate tmux window+pane resolved dynamically at click time. Run +# `fm-notify.sh install` once to register that protocol and launcher (idempotent; +# WSL only, since the launcher shells through wsl.exe - a clean no-op elsewhere). +# firstmate's own pane id is recorded to state/.fm-tmux-pane (under the firstmate +# home) so the click lands on the exact pane; fm-focus.sh falls back to the claude +# pane whose cwd is the home if it moved. +# +# Backends - every platform mirrors the Windows behavior as closely as it +# robustly allows (persistent + sounding + click-to-focus where supported): +# - WSL / native Windows -> raw WinRT toast via powershell.exe (no modules): +# persistent + sound + click-to-focus, fully wired. +# - macOS -> terminal-notifier when present (persistent alert + sound + +# -execute click-to-focus -> bin/fm-focus.sh); else `osascript display +# notification` WITH a sound (no persistence/click, the graceful fallback). +# - Linux desktop -> notify-send --urgency=critical (persistent), a best-effort +# sound via canberra-gtk-play/paplay, and notify-send --action click-to- +# focus -> bin/fm-focus.sh where the desktop's daemon supports actions. +# The macOS and Linux paths are implemented and unit-tested for dispatch and +# argument-building, but NOT end-to-end verified (firstmate develops on WSL). What +# each delivers and needs installed is documented in AGENTS.md. +# +# Internal/testing knobs (not part of the public contract): +# FM_NOTIFY_UNAME override `uname -s` for platform detection tests. +# FM_NOTIFY_PROC_VERSION path read instead of /proc/version (WSL detection). +# FM_TMUX_PANE_FILE override the recorded-pane-id path (default derives +# from the firstmate home: <home>/state/.fm-tmux-pane). +# FM_NOTIFY_BG 1 (default) backgrounds the dispatch; 0 runs it +# synchronously (deterministic for tests). +# FM_NOTIFY_WIN_SOUND Windows toast <audio> src (default the standard +# notification ding); operator-trusted, not user text. +# FM_NOTIFY_MACOS_SOUND macOS sound name for terminal-notifier/osascript +# (default Ping). +set -u + +# Platform detection and the shared dynamic-pane resolution live in the sibling +# lib so the notifier and bin/fm-focus.sh share one definition. Sourcing by this +# script's own dir keeps it correct whether run or sourced. +# shellcheck source=bin/fm-focus-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/fm-focus-lib.sh" + +# --------------------------------------------------------------------------- +# Pure helpers (no side effects) - exercised directly by tests/fm-notify.test.sh. +# --------------------------------------------------------------------------- + +# fm_notify_enabled: 0 when notifications are on (the default), 1 when the +# FM_NOTIFY toggle disables them. Unset means on. +fm_notify_enabled() { + case "$(printf '%s' "${FM_NOTIFY:-on}" | tr '[:upper:]' '[:lower:]')" in + off|0|false|no|disable|disabled) return 1 ;; + *) return 0 ;; + esac +} + +# Platform detection (fm_detect_platform / _uname_s) is provided by the sourced +# bin/fm-focus-lib.sh - one definition shared with bin/fm-focus.sh. + +# _fm_notify_self_dir: absolute dir of THIS script (bin/), resolved from its own +# BASH_SOURCE so it is correct whether run or sourced. +_fm_notify_self_dir() { cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd; } + +# _fm_notify_home: the firstmate home. From FM_HOME when set, else FM_ROOT_OVERRIDE, +# else this script's repo root (bin/ -> ..). The pane-id file and the click-to-focus +# launcher both derive from this; nothing is hardcoded. +_fm_notify_home() { + local root + root="${FM_ROOT_OVERRIDE:-$(cd "$(_fm_notify_self_dir)/.." 2>/dev/null && pwd)}" + printf '%s' "${FM_HOME:-$root}" +} + +# _fm_notify_pane_file: the stable path where firstmate records its own tmux pane +# id and fm-focus.sh reads it back. FM_TMUX_PANE_FILE overrides; the default lives +# under the firstmate home, never an absolute literal. +_fm_notify_pane_file() { + printf '%s' "${FM_TMUX_PANE_FILE:-$(_fm_notify_home)/state/.fm-tmux-pane}" +} + +# _b64_utf16: base64 of the UTF-16LE bytes of a string. The toast title/message +# are passed to PowerShell this way (decoded inside) so arbitrary text - including +# an escalation summary - can never break out of the script or need quoting. +# `base64 | tr -d '\n'` instead of GNU-only `base64 -w0`: BSD/macOS base64 has no +# -w flag, and stripping newlines yields the same single-line output everywhere. +_b64_utf16() { printf '%s' "$1" | iconv -f UTF-8 -t UTF-16LE 2>/dev/null | base64 2>/dev/null | tr -d '\n'; } + +# _xml_escape: escape the five XML metacharacters so a value is safe inside an XML +# attribute. The "Open PR" action's URL is interpolated into the toast XML as an +# attribute (unlike the title/message, which are set via DOM text nodes), so it is +# escaped here to keep a stray quote or angle bracket from breaking the toast +# markup. Escape & first so the entity ampersands it introduces are not re-escaped. +_xml_escape() { + printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g" +} + +# _build_windows_ps: assemble the PowerShell that shows a persistent, SOUNDING +# WinRT toast. The toast is scenario="reminder" (ToastGeneric) so it pops as a +# banner and stays until acted on, instead of dropping silently into the +# notification center, and carries an explicit <audio> so it always plays a sound +# (a toast that lands straight in the notification center otherwise makes no +# sound). With focus=1 (the default) it also carries a "Go to firstmate" action +# (and a body launch) wired to the "firstmate:" URL protocol, so a CLICK - never +# the toast itself - focuses the terminal+pane via bin/fm-focus.sh. Title/message +# are carried as base64 and set through DOM text nodes (never string-interpolated +# into the XML), so arbitrary escalation text can neither break the script nor +# inject toast markup. Uses the system PowerShell AppId (no third-party module); a +# custom "Firstmate" AppId is left out on purpose since an unregistered AppId can +# suppress the toast entirely. +_build_windows_ps() { + local title=$1 msg=$2 focus=$3 open_url=$4 tb mb toast_open focus_action openpr_action sound audio url_esc + tb=$(_b64_utf16 "$title") + mb=$(_b64_utf16 "$msg") + if [ "$focus" = 1 ]; then + toast_open='<toast scenario="reminder" launch="firstmate:focus" activationType="protocol">' + focus_action=' <action content="Go to firstmate" arguments="firstmate:focus" activationType="protocol"/>' + else + toast_open='<toast scenario="reminder">' + focus_action='' + fi + # Optional second button: "Open PR" opens the PR/MR URL in the default browser. + # activationType="protocol" with an https argument hands the URL to the OS, which + # routes it to the default browser. The URL is XML-attribute-escaped (above). + if [ -n "$open_url" ]; then + url_esc=$(_xml_escape "$open_url") + openpr_action=" <action content=\"Open PR\" activationType=\"protocol\" arguments=\"$url_esc\"/>" + else + openpr_action='' + fi + # Explicit, non-silent audio so the toast always sounds. The src is an + # operator-trusted internal knob (never user/escalation text), defaulting to the + # standard notification ding; loop="false" keeps the reminder scenario from + # looping an alarm. + sound="${FM_NOTIFY_WIN_SOUND:-ms-winsoundevent:Notification.Default}" + audio=" <audio src=\"$sound\" loop=\"false\"/>" + cat <<PS +\$ProgressPreference = 'SilentlyContinue' +\$ErrorActionPreference = 'Stop' +try { + \$Title = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$tb')) + \$Message = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$mb')) + \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] + \$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] + \$AppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' + \$xml = @' +$toast_open + <visual> + <binding template="ToastGeneric"> + <text></text> + <text></text> + </binding> + </visual> +$audio + <actions> +$focus_action +$openpr_action + <action content="Dismiss" arguments="dismiss" activationType="system"/> + </actions> +</toast> +'@ + \$doc = New-Object Windows.Data.Xml.Dom.XmlDocument + \$doc.LoadXml(\$xml) + \$nodes = \$doc.GetElementsByTagName('text') + \$null = \$nodes.Item(0).AppendChild(\$doc.CreateTextNode(\$Title)) + \$null = \$nodes.Item(1).AppendChild(\$doc.CreateTextNode(\$Message)) + \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$doc) + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(\$AppId).Show(\$toast) +} catch { } +PS +} + +# --------------------------------------------------------------------------- +# Dispatch (side-effecting). Each backend backgrounds its call unless +# FM_NOTIFY_BG=0, so a slow host call never blocks the caller. +# --------------------------------------------------------------------------- + +# _run_bg: run a command non-blocking (fully detached subshell) by default, or +# synchronously when FM_NOTIFY_BG=0 (deterministic for tests). +_run_bg() { + if [ "${FM_NOTIFY_BG:-1}" = 0 ]; then + "$@" >/dev/null 2>&1 + else + ( "$@" >/dev/null 2>&1 & ) + fi +} + +fm_notify_windows() { + local title=$1 msg=$2 focus=$3 open_url=$4 ps enc + command -v powershell.exe >/dev/null 2>&1 || return 0 + command -v iconv >/dev/null 2>&1 || return 0 + command -v base64 >/dev/null 2>&1 || return 0 + ps=$(_build_windows_ps "$title" "$msg" "$focus" "$open_url") + enc=$(printf '%s' "$ps" | iconv -f UTF-8 -t UTF-16LE 2>/dev/null | base64 2>/dev/null | tr -d '\n') || return 0 + [ -n "$enc" ] || return 0 + _run_bg powershell.exe -NoProfile -NonInteractive -EncodedCommand "$enc" +} + +_osa_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + +# _sh_squote: POSIX single-quote a string so it survives as ONE argument when the +# result is re-parsed by a shell (terminal-notifier's -execute runs its string +# through a shell). Only firstmate-controlled paths are quoted this way - never +# untrusted title/message text, which is passed as separate notifier arguments. +_sh_squote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +# fm_notify_macos: mirror the Windows toast as closely as macOS robustly allows. +# terminal-notifier (when present) gives a persistent alert + sound + a single +# click action. With an --open URL the click action OPENS THE PR (-open <url>), +# the most useful action for a done-state notification; otherwise it click-to- +# focuses via -execute, which runs bin/fm-focus.sh (with FM_HOME) to raise the +# terminal and select the firstmate pane. terminal-notifier has one click action, +# so -open takes priority over -execute when a URL is present (best-effort macOS +# parity). Without terminal-notifier, fall back to `osascript display +# notification` WITH a sound - no persistence/click, but still better than a silent +# banner. Title/message are passed as discrete arguments (terminal-notifier) or +# escaped osascript string literals, never shell-interpolated. +fm_notify_macos() { + local title=$1 msg=$2 focus=$3 focus_sh=$4 home=$5 open_url=$6 t m sound exe + sound="${FM_NOTIFY_MACOS_SOUND:-Ping}" + if command -v terminal-notifier >/dev/null 2>&1; then + if [ -n "$open_url" ]; then + _run_bg terminal-notifier -title "$title" -message "$msg" -sound "$sound" -open "$open_url" + elif [ "$focus" = 1 ] && [ -x "$focus_sh" ]; then + # -execute is shell-evaluated: carry only firstmate's own quoted paths. + exe="FM_HOME=$(_sh_squote "$home") $(_sh_squote "$focus_sh")" + _run_bg terminal-notifier -title "$title" -message "$msg" -sound "$sound" -execute "$exe" + else + _run_bg terminal-notifier -title "$title" -message "$msg" -sound "$sound" + fi + return 0 + fi + command -v osascript >/dev/null 2>&1 || return 0 + t=$(_osa_escape "$title"); m=$(_osa_escape "$msg") + # Fallback: no persistence or click on plain osascript, but add a sound. + _run_bg osascript -e "display notification \"$m\" with title \"$t\" sound name \"$sound\"" +} + +# _fm_notify_send_supports_action: 0 when this notify-send advertises --action in +# its help, i.e. the click-to-focus path is usable on this desktop's daemon. +_fm_notify_send_supports_action() { + notify-send --help 2>&1 | grep -q -- '--action' +} + +# _fm_linux_play_sound: best-effort, fully detached, never-fatal sound alongside +# notify-send. Tries an event sound (canberra-gtk-play), then a known freedesktop +# .oga via paplay/aplay - each guarded by tool AND file presence so it is a clean +# no-op when nothing is installed. +_fm_linux_play_sound() { + if command -v canberra-gtk-play >/dev/null 2>&1; then + ( canberra-gtk-play -i message >/dev/null 2>&1 & ) 2>/dev/null + return 0 + fi + local f + for f in /usr/share/sounds/freedesktop/stereo/message.oga \ + /usr/share/sounds/freedesktop/stereo/bell.oga; do + [ -r "$f" ] || continue + if command -v paplay >/dev/null 2>&1; then ( paplay "$f" >/dev/null 2>&1 & ) 2>/dev/null; return 0; fi + if command -v aplay >/dev/null 2>&1; then ( aplay "$f" >/dev/null 2>&1 & ) 2>/dev/null; return 0; fi + done + return 0 +} + +# _fm_linux_notify_action: post a critical (persistent) notification carrying up to +# two actions - a "Go to firstmate" click that runs bin/fm-focus.sh, and, with an +# --open URL, an "Open PR" click that runs xdg-open on the URL. notify-send --wait +# blocks until a click (or the daemon's timeout) and prints the chosen action key, +# so the caller always backgrounds this (it runs inside _run_bg). want_focus gates +# the focus action; a non-empty open_url adds the Open-PR action. Arrays are avoided +# for bash 3.2 safety, so the notify-send call is enumerated over the cases. +# Best-effort and never-fatal. +_fm_linux_notify_action() { + local title=$1 msg=$2 focus_sh=$3 home=$4 open_url=$5 want_focus=$6 act + if [ "$want_focus" = 1 ] && [ -n "$open_url" ]; then + act=$(notify-send --urgency=critical --wait \ + --action='focus=Go to firstmate' --action='openpr=Open PR' -- "$title" "$msg" 2>/dev/null) + elif [ "$want_focus" = 1 ]; then + act=$(notify-send --urgency=critical --wait --action='focus=Go to firstmate' -- "$title" "$msg" 2>/dev/null) + else + act=$(notify-send --urgency=critical --wait --action='openpr=Open PR' -- "$title" "$msg" 2>/dev/null) + fi + case "$act" in + focus) FM_HOME="$home" "$focus_sh" >/dev/null 2>&1 ;; + openpr) command -v xdg-open >/dev/null 2>&1 && xdg-open "$open_url" >/dev/null 2>&1 ;; + esac + return 0 +} + +# fm_notify_linux: mirror the Windows toast as closely as the Linux desktop +# robustly allows. --urgency=critical makes the notification persist; a best- +# effort sound plays alongside; and where the notification daemon supports +# actions, a "Go to firstmate" click runs bin/fm-focus.sh and (with an --open URL) +# an "Open PR" click runs xdg-open. Actions are desktop-environment-dependent, so +# it degrades to persistent+sound when the daemon has no action support. notify-send +# takes title/message as discrete arguments, never shell-interpolated. +fm_notify_linux() { + local title=$1 msg=$2 focus=$3 focus_sh=$4 home=$5 open_url=$6 want_focus=0 + command -v notify-send >/dev/null 2>&1 || return 0 + _fm_linux_play_sound + [ "$focus" = 1 ] && [ -x "$focus_sh" ] && want_focus=1 + if { [ "$want_focus" = 1 ] || [ -n "$open_url" ]; } && _fm_notify_send_supports_action; then + _run_bg _fm_linux_notify_action "$title" "$msg" "$focus_sh" "$home" "$open_url" "$want_focus" + else + _run_bg notify-send --urgency=critical -- "$title" "$msg" + fi +} + +# --------------------------------------------------------------------------- +# Pluggable backend seam (firstmate issue #106). +# --------------------------------------------------------------------------- +# fm-notify is the FIRST backend (native OS toast + click-to-focus) of the +# pluggable notifier. Additional out-of-band backends plug in here - notably a phone push +# via ntfy or Pushover, since the captain carries an iPhone. To add one: write a +# fm_notify_push_<service> dispatcher gated by its own env/config and call it +# from fm_notify_dispatch alongside the native path. Left as a clean seam on +# purpose; no remote-push backend is built yet. + +# fm_notify_dispatch: route to the right native backend for this platform. The +# single place a future push backend hooks in. The macOS/Linux backends receive +# the click-to-focus handler path and the firstmate home so their click action +# can run bin/fm-focus.sh with the right FM_HOME (Windows resolves both itself +# through the installed protocol launcher). +fm_notify_dispatch() { + local title=$1 msg=$2 focus=$3 open_url=${4:-} focus_sh home + focus_sh="$(_fm_notify_self_dir)/fm-focus.sh" + home=$(_fm_notify_home) + case "$(fm_detect_platform)" in + wsl|windows) fm_notify_windows "$title" "$msg" "$focus" "$open_url" ;; + macos) fm_notify_macos "$title" "$msg" "$focus" "$focus_sh" "$home" "$open_url" ;; + linux) fm_notify_linux "$title" "$msg" "$focus" "$focus_sh" "$home" "$open_url" ;; + *) return 0 ;; # unknown platform: quiet no-op + esac + # Future: fm_notify_push_ntfy "$title" "$msg" (remote push, issue #106) +} + +# --------------------------------------------------------------------------- +# Click-to-focus arming (the "firstmate:" protocol + hidden launcher + pane id). +# --------------------------------------------------------------------------- + +# fm_notify_record_pane: record firstmate's OWN tmux pane id to the stable file +# fm-focus.sh reads, so a click lands on the exact pane. The path derives from the +# firstmate home (never hardcoded). A quiet no-op (still exits 0) when not inside +# tmux. Echoes the file path on success so the install step can report it. +fm_notify_record_pane() { + local pane_file pane + pane_file=$(_fm_notify_pane_file) + command -v tmux >/dev/null 2>&1 || return 0 + pane=$(tmux display-message -p '#{pane_id}' 2>/dev/null) || return 0 + [ -n "$pane" ] || return 0 + mkdir -p "$(dirname "$pane_file")" 2>/dev/null || true + printf '%s\n' "$pane" > "$pane_file" 2>/dev/null || return 0 + printf '%s\n' "$pane_file" +} + +# fm_notify_install: arm click-to-focus. Records firstmate's pane id (any platform +# with tmux), and on WSL registers the "firstmate:" URL protocol under HKCU and +# writes the hidden VBS launcher that runs bin/fm-focus.sh. Fully idempotent - +# safe to run repeatedly - and a clean no-op for the protocol bits off WSL (the +# launcher shells through wsl.exe, so native Windows/MINGW/Cygwin can't use it). +# Prints a short human summary. The launcher and protocol command carry no captain +# secrets; only local filesystem paths. +fm_notify_install() { + local self_dir focus_sh pane_file platform home + self_dir=$(_fm_notify_self_dir) + focus_sh="$self_dir/fm-focus.sh" + + # Always: record firstmate's pane id so a click lands on the exact pane. + if pane_file=$(fm_notify_record_pane) && [ -n "$pane_file" ]; then + printf 'fm-notify: recorded firstmate pane id -> %s\n' "$pane_file" + else + printf 'fm-notify: no tmux pane to record (focus will fall back to cwd match)\n' + fi + + # WSL only: the launcher shells through wsl.exe and the install needs wslpath, + # so a native Windows (MINGW/Cygwin) shell can't arm this path. Skip cleanly there. + platform=$(fm_detect_platform) + case "$platform" in + wsl) : ;; + *) printf 'fm-notify: %s - click-to-focus protocol install skipped (WSL only)\n' "$platform"; return 0 ;; + esac + + if [ ! -x "$focus_sh" ]; then + printf 'fm-notify: focus handler not found/executable at %s\n' "$focus_sh" >&2 + return 1 + fi + command -v reg.exe >/dev/null 2>&1 || { printf 'fm-notify: reg.exe not found - cannot register the firstmate: protocol\n' >&2; return 1; } + command -v wslpath >/dev/null 2>&1 || { printf 'fm-notify: wslpath not found - cannot resolve a Windows launcher path\n' >&2; return 1; } + command -v cmd.exe >/dev/null 2>&1 || { printf 'fm-notify: cmd.exe not found - cannot resolve %%LOCALAPPDATA%%\n' >&2; return 1; } + + # Resolve a stable Windows-accessible launcher dir under %LOCALAPPDATA%. + local appdata_win appdata_wsl vbs_dir vbs_wsl vbs_win wsl_cmd vbs_focus vbs_home + appdata_win=$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r\n') + [ -n "$appdata_win" ] || { printf 'fm-notify: could not resolve %%LOCALAPPDATA%%\n' >&2; return 1; } + appdata_wsl=$(wslpath -u "$appdata_win" 2>/dev/null) + [ -n "$appdata_wsl" ] || { printf 'fm-notify: could not translate %s to a WSL path\n' "$appdata_win" >&2; return 1; } + vbs_dir="$appdata_wsl/firstmate" + mkdir -p "$vbs_dir" || { printf 'fm-notify: could not create %s\n' "$vbs_dir" >&2; return 1; } + vbs_wsl="$vbs_dir/fm-launch.vbs" + + # The hidden launcher: window style 0 so no console flashes. Pin the distro so a + # multi-distro host still reaches the right bash, and run fm-focus.sh under the + # SAME firstmate home that armed the pane file - so a custom home clicks into the + # pane it recorded, matching the macOS/Linux backends which also pass the home + # through. WScript.Shell.Run does NOT preserve an `env VAR=...` wrapper or quotes + # around the distro, so neither survives the VBS -> wsl.exe chain: the distro is + # passed UNQUOTED and the home is passed as bash's positional $1 (fm-focus.sh + # reads $1 as FM_HOME). focus_sh and home are still VBS-escaped (embedded " -> "") + # and wrapped in VBS double quotes, so a checkout path containing spaces still + # reaches bash as one argument. + home=$(_fm_notify_home) + vbs_focus=${focus_sh//\"/\"\"} + vbs_home=${home//\"/\"\"} + if [ -n "${WSL_DISTRO_NAME:-}" ]; then + wsl_cmd="wsl.exe -d ${WSL_DISTRO_NAME} -e bash \"\"${vbs_focus}\"\" \"\"${vbs_home}\"\"" + else + wsl_cmd="wsl.exe -e bash \"\"${vbs_focus}\"\" \"\"${vbs_home}\"\"" + fi + printf 'CreateObject("WScript.Shell").Run "%s", 0, False\r\n' "$wsl_cmd" > "$vbs_wsl" \ + || { printf 'fm-notify: could not write the launcher %s\n' "$vbs_wsl" >&2; return 1; } + + vbs_win=$(wslpath -w "$vbs_wsl" 2>/dev/null) + [ -n "$vbs_win" ] || { printf 'fm-notify: could not translate %s to a Windows path\n' "$vbs_wsl" >&2; return 1; } + + # Register the firstmate: URL protocol (idempotent; /f overwrites). The open + # command runs the launcher hidden via wscript; %1 (the invoked URL) is passed + # through and ignored by the launcher. Every write is checked, so ANY failure + # fails the install instead of falsely reporting "armed". + if ! { reg.exe add 'HKCU\Software\Classes\firstmate' /ve /d 'URL:firstmate Protocol' /f >/dev/null 2>&1 \ + && reg.exe add 'HKCU\Software\Classes\firstmate' /v 'URL Protocol' /d '' /f >/dev/null 2>&1 \ + && reg.exe add 'HKCU\Software\Classes\firstmate\shell\open\command' /ve \ + /d "wscript.exe //B //Nologo \"$vbs_win\" \"%1\"" /f >/dev/null 2>&1; }; then + printf 'fm-notify: reg.exe failed to register the firstmate: protocol\n' >&2 + return 1 + fi + + printf 'fm-notify: registered firstmate: -> %s -> %s\n' "$vbs_win" "$focus_sh" + printf 'fm-notify: click-to-focus armed\n' + return 0 +} + +# --------------------------------------------------------------------------- +# Entry point. +# --------------------------------------------------------------------------- +fm_notify_main() { + local usage='usage: fm-notify.sh "<title>" "<message>" [--focus|--no-focus] [--open <url>] | fm-notify.sh install' + # Subcommand: arm click-to-focus (protocol + launcher + pane id). + case "${1:-}" in + install|ensure|--install) fm_notify_install; return $? ;; + esac + + # Focus is ON BY DEFAULT: every toast pops, sounds, persists, and is click-to- + # focus. --no-focus opts out (tests only); --focus is accepted for explicitness. + # --open <url> (or --open=<url>) adds the optional "Open PR" action button. + local title="" msg="" focus=1 open_url="" nargs=0 + while [ $# -gt 0 ]; do + case "$1" in + --focus) focus=1 ;; + --no-focus) focus=0 ;; + --open) + # --open requires a following URL; reject a bare --open instead of + # silently dropping the button and reporting success. + [ $# -ge 2 ] || { echo "$usage" >&2; return 2; } + shift + open_url=$1 + ;; + --open=*) + open_url=${1#--open=} + [ -n "$open_url" ] || { echo "$usage" >&2; return 2; } + ;; + --) shift; break ;; + *) + case $nargs in + 0) title=$1 ;; + 1) msg=$1 ;; + esac + nargs=$((nargs + 1)) + ;; + esac + shift + done + # Any positionals after `--`. + while [ $# -gt 0 ]; do + case $nargs in 0) title=$1 ;; 1) msg=$1 ;; esac + nargs=$((nargs + 1)); shift + done + + # The documented usage requires BOTH a title and a message; reject either + # missing so a miswired notifier call fails fast instead of looking successful. + if [ -z "$title" ] || [ -z "$msg" ]; then + echo "$usage" >&2 + return 2 + fi + + # Toggle check AFTER arg parsing so a bad invocation still reports usage. + fm_notify_enabled || return 0 + + fm_notify_dispatch "$title" "$msg" "$focus" "$open_url" + return 0 +} + +# Run only when executed, not when sourced (tests source the pure helpers). +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + fm_notify_main "$@" +fi diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index e7b968502..114655058 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -116,6 +116,13 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-classify-lib.sh . "$FM_DAEMON_DIR/fm-classify-lib.sh" +# The notifier's pure helpers, for ONE definition of the FM_NOTIFY toggle. The +# escalation notifier hook (notify_escalation) gates on fm_notify_enabled BEFORE +# running any command, so FM_NOTIFY=off silences even an FM_NOTIFY_CMD override +# whose own script would not consult the toggle. Source-safe (BASH_SOURCE guard). +# shellcheck source=bin/fm-notify.sh +. "$FM_DAEMON_DIR/fm-notify.sh" + # --- tunables --------------------------------------------------------------- FM_SUPERVISOR_TARGET_DEFAULT="firstmate:0" INJECT_SKIP_DEFAULT="heartbeat" @@ -415,6 +422,74 @@ escalate_add() { # <state> <distilled-item> printf '%s\n' "$item" >> "$buf" } +# Fire a best-effort native OS notification (and focus the host terminal) the +# moment an away-mode escalation digest is actually delivered to the captain, so +# they get pinged out of band while away from the pane. This is the issue #106 +# notifier hook. It runs ONLY on a confirmed inject of a classified escalation - +# never on routine self-handled wakes - so it cannot ping for benign churn. The +# toast text is a clean count-and-class summary with no task ids or internal +# vocabulary, since it can surface on a lock screen. Fully best-effort: the +# notifier honors FM_NOTIFY=off itself and a missing/failing notifier never +# affects escalation delivery. FM_NOTIFY_CMD overrides the notifier path (tests). +# The FM_NOTIFY=off gate is enforced HERE, before any command runs, so a custom +# FM_NOTIFY_CMD that does not consult the toggle is silenced too (the built-in +# notifier also self-gates, but the override must not be a bypass). +# Find an "Open PR" URL for the escalation digest: the first GitHub PR / GitLab MR +# URL on a done-state buffered line whose task is NOT local-only. Buffered signal +# lines are "<task>.status: <status> | <task2>.status: <status2>" (the catch-all +# scan appends "<task>.status: <status> (catch-all scan)"); split on " | " so each +# "<task>.status: <rest>" segment is examined independently, and return the first +# URL whose task's recorded mode is not local-only. Empty when none. Best-effort: +# non-signal buffered lines (e.g. stale-terminal) carry no parseable task and are +# skipped, so they simply get no button. +notify_open_url_for_buffer() { # <state> <buffer-file> + local state=$1 buf=$2 seg task rest url + [ -r "$buf" ] || return 0 + while IFS= read -r seg; do + case "$seg" in *.status:\ *) ;; *) continue ;; esac + task=${seg%%.status: *}; task=${task##* } + rest=${seg#*.status: } + case "$rest" in *[Dd]one*) ;; *) continue ;; esac + url=$(fm_status_pr_url "$rest") + [ -n "$url" ] || continue + fm_task_is_local_only "$state" "$task" && continue + printf '%s' "$url" + return 0 + done < <(awk '{gsub(/ \| /,"\n"); print}' "$buf") + return 0 +} + +notify_escalation() { # <state> <count> + local state=$1 n=$2 buf notify summary open_url decision=0 + fm_notify_enabled || return 0 + notify="${FM_NOTIFY_CMD:-$FM_DAEMON_DIR/fm-notify.sh}" + [ -x "$notify" ] || return 0 + buf="$state/.subsuper-escalations" + if grep -qiE 'needs-decision|blocked|failed' "$buf" 2>/dev/null; then + summary="$n item(s) need your decision" + decision=1 + elif grep -qiE 'done|merged|checks green|ready|PR ' "$buf" 2>/dev/null; then + summary="$n item(s) ready for review" + else + summary="$n update(s) need your attention" + fi + # A done-state escalation carrying a PR/MR URL (non-local-only task) also gets + # the "Open PR" button via --open. Best-effort, like the rest of the hook. The + # button is reserved for review/done-state digests: a digest classified as a + # decision wake never carries --open, even if a done: line is also buffered, so + # the action stays consistent with the notification class. + if [ "$decision" = 1 ]; then + open_url="" + else + open_url=$(notify_open_url_for_buffer "$state" "$buf") + fi + if [ -n "$open_url" ]; then + "$notify" "Firstmate" "$summary" --focus --open "$open_url" >/dev/null 2>&1 || true + else + "$notify" "Firstmate" "$summary" --focus >/dev/null 2>&1 || true + fi +} + # Flush the escalation buffer as ONE batched, single-line digest to the # supervisor pane. Returns 0 on successful inject (or empty buffer), non-zero on # inject failure (buffer preserved for retry / catch-up). @@ -428,7 +503,12 @@ escalate_flush() { # <state> # Single-line wrapper: no embedded newlines (inject_msg also collapses as a # safety net, but keeping the source single-line makes the intent explicit). msg=$(printf 'Supervisor escalate (%s event(s)): %s (pre-read; re-arm not needed — watcher daemon-managed)' "$n" "$msg") - if inject_msg "$msg" "$state"; then : > "$buf"; rm -f "${buf}.since" "$state/.subsuper-inject-wedged"; return 0; fi + if inject_msg "$msg" "$state"; then + # Captain-relevant digest delivered: fire the out-of-band notifier (best- + # effort) BEFORE clearing the buffer so the summary can read its contents. + notify_escalation "$state" "$n" + : > "$buf"; rm -f "${buf}.since" "$state/.subsuper-inject-wedged"; return 0 + fi return 1 } diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 2eb282421..5b413026a 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -36,6 +36,11 @@ mkdir -p "$STATE" # has one definition. # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# The notifier's pure helpers, for ONE definition of the FM_NOTIFY toggle shared +# with bin/fm-notify.sh and the daemon. The normal-mode outbound notifier hook +# (notify_signal_statuses) gates on fm_notify_enabled. Source-safe (BASH_SOURCE guard). +# shellcheck source=bin/fm-notify.sh +. "$SCRIPT_DIR/fm-notify.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -256,6 +261,90 @@ mark_all_captain_relevant_surfaced() { done < <(scan_captain_relevant_statuses "$STATE") } +# Outbound notifier path: FM_NOTIFY_CMD overrides (tests), else the sibling +# bin/fm-notify.sh, mirroring the daemon's notify_escalation. +NOTIFY_CMD="${FM_NOTIFY_CMD:-$SCRIPT_DIR/fm-notify.sh}" +# Per-task content dedup marker for the notify hook - the watcher's analogue of +# the daemon's .subsuper-seen-status-<task>: the last captain-relevant status line +# already toasted, so the same status is never toasted twice. +_notify_seen_path() { printf '%s/.notify-seen-%s' "$STATE" "$(printf '%s' "$1" | tr ':/.' '___')"; } + +# Outbound notifier hook for NORMAL (non-afk) supervision: fire a best-effort OS +# toast when a signal wake involves NEW captain-relevant status lines, so the +# captain is pinged out of band even when not away. It MIRRORS the daemon's +# notify_escalation filtering (the same decision/review summary classes) so the +# two paths cannot drift, and it is held to four hard properties: +# - gated on fm_notify_enabled, so FM_NOTIFY=off silences it; +# - ONLY captain-relevant verbs (working:/turn-end/heartbeat never toast); +# - deduped per task by content (.notify-seen-<task>), so the same status is +# not toasted twice (e.g. a status write + the same turn's turn-end); +# - fully ERROR-ISOLATED and backgrounded: it always returns 0 and the toast is +# detached, so a missing, failing, or slow notifier can NEVER change the +# watcher's wake/exit behavior. FM_WATCH_NOTIFY_BG=0 runs it synchronously +# (deterministic for tests); it is still error-swallowed there. +# Pass the same space-separated status-path list as the signal triage. +notify_signal_statuses() { # <file> ... + fm_notify_enabled || return 0 + [ -x "$NOTIFY_CMD" ] || return 0 + local f last task seen n=0 decision=0 review=0 summary open_url="" url + for f in "$@"; do + case "$f" in *.status) ;; *) continue ;; esac + [ -e "$f" ] || continue + last=$(last_status_line "$f") + status_is_captain_relevant "$last" || continue + task=$(basename "$f"); task="${task%.status}" + seen=$(_notify_seen_path "$task") + [ "$(cat "$seen" 2>/dev/null || true)" = "$last" ] && continue + printf '%s' "$last" > "$seen" 2>/dev/null || true + n=$((n + 1)) + if printf '%s' "$last" | grep -qiE 'needs-decision|blocked|failed'; then + decision=1 + elif printf '%s' "$last" | grep -qiE 'done|merged|checks green|ready|PR '; then + review=1 + fi + # A done-state status that carries a PR/MR URL earns an "Open PR" button, so + # the captain can jump straight to the PR from the toast. Skipped for + # local-only tasks (no PR). The first eligible URL wins (one button), so a + # multi-task wake still gets at most one Open-PR target. + if [ -z "$open_url" ] && printf '%s' "$last" | grep -qiE 'done'; then + url=$(fm_status_pr_url "$last") + if [ -n "$url" ] && ! fm_task_is_local_only "$STATE" "$task"; then + open_url=$url + fi + fi + done + [ "$n" -gt 0 ] || return 0 + if [ "$decision" = 1 ]; then + summary="$n item(s) need your decision" + elif [ "$review" = 1 ]; then + summary="$n item(s) ready for review" + else + summary="$n update(s) need your attention" + fi + # The "Open PR" button is reserved for review/done-state toasts: a wake + # classified as a decision (needs-decision/blocked/failed wins over a done: line + # in the same batch) never carries --open, so the action stays consistent with + # the notification class instead of pointing at a different item. + [ "$decision" = 1 ] && open_url="" + # The toast text is an id-free count-and-class summary (lock-screen safe), the + # same shape the daemon emits. With a PR URL it also carries --open so the toast + # gets the "Open PR" button. Error-isolated either way. + if [ "${FM_WATCH_NOTIFY_BG:-1}" = 0 ]; then + if [ -n "$open_url" ]; then + "$NOTIFY_CMD" "Firstmate" "$summary" --focus --open "$open_url" >/dev/null 2>&1 || true + else + "$NOTIFY_CMD" "Firstmate" "$summary" --focus >/dev/null 2>&1 || true + fi + else + if [ -n "$open_url" ]; then + ( "$NOTIFY_CMD" "Firstmate" "$summary" --focus --open "$open_url" >/dev/null 2>&1 & ) 2>/dev/null || true + else + ( "$NOTIFY_CMD" "Firstmate" "$summary" --focus >/dev/null 2>&1 & ) 2>/dev/null || true + fi + fi + return 0 +} + # Cheap heartbeat fleet-scan (the always-on twin of the daemon's catch-all). 0 if # any captain-relevant status has NOT already been surfaced to firstmate (its # content differs from the .hb-surfaced-<task> marker). Pure detect, no side @@ -356,6 +445,11 @@ EOF done <<EOF $pending EOF + # Normal-mode outbound notifier: toast NEW captain-relevant statuses. Skipped + # while afk, where the daemon owns notifications (notify_escalation). Fully + # error-isolated, so it can never affect the enqueue/wake/exit below. + # shellcheck disable=SC2086 # $files is a space-separated status-path list (ids carry no spaces) + afk_present || notify_signal_statuses $files || true wake "$reason" else while IFS=$(printf '\t') read -r sf sig f; do diff --git a/docs/architecture.md b/docs/architecture.md index 30ad40e3d..70770fd2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,8 @@ firstmate's full operating manual for the orchestrator agent itself is [`AGENTS. A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable. Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, check-script output such as PR merge polling or an X mention, terminal stale panes, non-terminal stale panes whose crew is not provably working, provably-working non-terminal stale panes that persist past `FM_STALE_ESCALATE_SECS`, and heartbeat backstop hits. +On a captain-relevant signal in normal operation the watcher also fires the out-of-band notifier (`bin/fm-notify.sh`), so the captain is pinged off the pane the moment work needs them - not only in away mode; the hook is gated on `FM_NOTIFY`, deduped per task, error-isolated and backgrounded so it can never change the wake, and stands down while afk (the daemon owns notifications then). +When that signal is a `done:` carrying a PR/MR URL on a non-`local-only` task, the notification also gets a second "Open PR" button (`--open <url>`) so the captain can jump straight to the PR. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. No-verb wakes, such as `working:` notes, bare turn-ended signals, and fresh non-terminal stale panes, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step for that crew's branch or a pane busy signature. No-change heartbeats are also benign. @@ -31,6 +33,10 @@ A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs and status-scan primitives. The always-on watcher also uses that library's provably-working predicate on no-verb signal and non-terminal-stale paths, while the daemon keeps its away-mode stale recheck unchanged. The daemon escalates only captain-relevant events as one batched, single-line digest (prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages). +On a confirmed escalation delivery, it also fires a best-effort native OS notification through `bin/fm-notify.sh` - a persistent WinRT toast on WSL/Windows, `osascript` on macOS, or `notify-send` on Linux - so the captain is pinged out of band while away from the pane; the summary carries only an id-free count and class, the `FM_NOTIFY` toggle silences it (the daemon hook re-checks the toggle so a custom `FM_NOTIFY_CMD` cannot bypass it), and a missing or failing notifier never affects escalation delivery. +When the delivered digest includes a `done:` line carrying a PR/MR URL on a non-`local-only` task, the toast also gets the "Open PR" button (`--open <url>`). +On WSL/Windows the toast is persistent (`scenario="reminder"`) and carries an opt-in click-to-focus action: clicking it runs the registered `firstmate:` URL protocol -> a hidden VBS launcher -> `bin/fm-focus.sh`, which raises Windows Terminal and selects the firstmate tmux window+pane resolved dynamically (nothing hardcoded); `bin/fm-notify.sh install` arms the protocol and launcher idempotently, and the toast never auto-focuses. +This is the first backend of the pluggable issue #106 notifier, with a marked seam for a later remote push (ntfy or Pushover). Its injection path shares `bin/fm-tmux-lib.sh` with `fm-send.sh`, so dim-ghost-aware and border-aware composer detection plus verified submit retry stay consistent; stalled escalation delivery raises `state/.subsuper-inject-wedged` after `FM_MAX_DEFER_SECS` instead of silently deferring forever. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using the target's recorded `harness=` meta, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. diff --git a/docs/configuration.md b/docs/configuration.md index 5ac94b805..c31f9656d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,4 +157,10 @@ FM_CRASH_BACKOFF=60 # seconds to wait after crossing the crash th FM_CRASH_NORMAL_SLEEP=5 # seconds to wait after an isolated watcher crash FM_LOG_MAX_BYTES=1048576 # daemon log size that triggers trimming FM_LOG_KEEP_LINES=2000 # daemon log lines kept when trimming +# out-of-band notifier (bin/fm-notify.sh); fired by the watcher on a captain-relevant signal in normal mode and by the daemon on a confirmed escalation while afk +FM_NOTIFY=on # native OS notification toggle; off/0/false/no/disabled silences every notification (also gated daemon-side so a custom FM_NOTIFY_CMD cannot bypass it) +# click-to-focus (WSL/Windows): `fm-notify.sh install` registers the firstmate: protocol + hidden launcher; firstmate's pane id is recorded here for fm-focus.sh +FM_TMUX_PANE_FILE=$FM_HOME/state/.fm-tmux-pane # override the recorded-pane-id path read by bin/fm-focus.sh (default derives from the firstmate home) +FM_FOCUS_MACOS_TERM= # macOS: terminal app fm-focus.sh activates on a click; empty auto-detects iTerm2/Terminal (best-effort) +FM_FOCUS_LINUX_WINDOW= # Linux: window title fm-focus.sh raises via wmctrl/xdotool on a click; empty falls back to a known terminal class (best-effort) ``` diff --git a/docs/scripts.md b/docs/scripts.md index 137a49ed7..2631c2c64 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -21,13 +21,16 @@ Each file also starts with a short header comment. | `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | | `fm-watch.sh` | Singleton-safe always-on watcher; absorbs no-verb signal and stale wakes only when the crew is provably working, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | | `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | +| `fm-notify.sh` | First backend of the pluggable issue #106 notifier: fire a best-effort native OS notification (persistent `scenario="reminder"` WinRT toast on WSL/Windows, `osascript` on macOS, `notify-send` on Linux); with `--focus` the toast carries a click-to-focus action wired to the `firstmate:` protocol; with `--open <url>` it adds a second "Open PR" button that opens the URL (real toast button on Windows, `terminal-notifier -open`/`notify-send --action xdg-open` best-effort on macOS/Linux); `install` arms that protocol + hidden launcher and records the pane id; non-blocking, gated by `FM_NOTIFY`, fired by the watcher on a captain-relevant signal in normal mode and by the daemon on a confirmed away-mode escalation (a done-state PR escalation on a non-`local-only` task carries the Open-PR button) | +| `fm-focus.sh` | Click-to-focus handler run by the Windows `firstmate:` URL protocol: raise Windows Terminal and select the firstmate tmux window+pane, resolved dynamically at click time (recorded pane id, else the claude pane whose cwd is the firstmate home); nothing hardcoded | +| `fm-focus-lib.sh` | Shared platform detection and dynamic tmux pane resolution sourced by `fm-notify.sh` and `fm-focus.sh` so the notifier and the click-to-focus handler cannot drift | | `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, even when the pane has closed, with pane and status-log fallback | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for `/updatefirstmate` origin pulls and no-fetch local secondmate syncs | | `fm-tasks-axi-lib.sh` | Shared `tasks-axi` compatibility probe sourced by bootstrap and teardown | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | | `fm-wake-lib.sh` | Shared durable wake queue and portable lock helpers sourced by the watcher, drain, arm, guard, and daemon | -| `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | +| `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate and the notifier hooks' PR/MR-URL and local-only-mode helpers | | `fm-send.sh` | Send one verified literal line (or `--key Escape`) to a direct-report window; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before Enter; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | | `fm-peek.sh` | Print a bounded tail of a crewmate pane | diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 3f9791735..8fca150e7 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -27,6 +27,10 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DAEMON="$ROOT/bin/fm-supervise-daemon.sh" +# Keep this e2e from firing real OS notifications when the daemon flushes an +# escalation (escalate_flush -> notify_escalation -> bin/fm-notify.sh). +export FM_NOTIFY=off + # Skip gracefully if tmux is not installed. command -v tmux >/dev/null 2>&1 || { echo "skip: tmux not found"; exit 0; } diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 1a316d573..6a5c81203 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -20,6 +20,12 @@ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then . "$DAEMON" fi +# Keep the suite from firing real OS notifications via the escalation hook +# (escalate_flush -> notify_escalation -> bin/fm-notify.sh): a confirmed flush on +# a developer's WSL/macOS box would otherwise pop a toast per flush. The hook's +# own behavior is covered deterministically by test_escalate_flush_fires_notify. +export FM_NOTIFY=off + TMP_ROOT=$(fm_test_tmproot fm-daemon-tests) @@ -156,6 +162,173 @@ test_escalate_batches_into_one_digest() { pass "multiple escalations flush as a single batched digest" } +test_escalate_flush_fires_notify() { + # The issue #106 hook: a CONFIRMED escalation flush fires the native notifier + # (best-effort) with a clean, id-free summary, and only after a successful + # inject. A failed inject (afk off) must NOT notify. FM_NOTIFY_CMD points the + # daemon at a fake notifier that logs its args, so this is deterministic and + # never pops a real toast. + local dir state fakebin sent capture notifylog fakenotify + dir=$(make_supercase notify-hook) + state="$dir/state" + fakebin="$dir/fakebin" + sent="$dir/sent.log"; : > "$sent" + capture="$dir/pane.txt"; : > "$capture" + notifylog="$dir/notify.log"; : > "$notifylog" + fakenotify="$dir/fake-notify.sh" + cat > "$fakenotify" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$notifylog" +exit 0 +SH + chmod +x "$fakenotify" + + # Successful flush -> notifier fired. + escalate_add "$state" "fin-t5.status: needs-decision: pick A or B" + afk_enter "$state" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY='' \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" || fail "escalate_flush failed" + [ -s "$notifylog" ] || fail "confirmed flush did not fire the notifier" + local args + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "Firstmate" "notifier title missing" + assert_contains "$args" "--focus" "notifier was not asked to focus the host" + assert_contains "$args" "need your decision" "summary did not reflect the needs-decision class" + assert_not_contains "$args" "fin-t5" "task id leaked into the notification summary" + assert_not_contains "$args" ".status" "internal status filename leaked into the summary" + + # Failed flush (afk inactive -> inject deferred) must NOT notify. + : > "$notifylog" + afk_exit "$state" + escalate_add "$state" "done: PR https://x/y/pull/1" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY='' \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" \ + && fail "escalate_flush should fail while afk inactive" + [ -s "$notifylog" ] && fail "notifier fired on a failed (un-delivered) flush" + pass "escalate_flush fires the native notifier only on a confirmed delivery" +} + +test_escalate_flush_passes_open_pr_url() { + # A confirmed flush whose buffered done: line carries a PR URL (non-local-only + # task) passes --open <url> so the toast gets the "Open PR" button. A local-only + # task's done: line is gated out (no PR). The buffered signal form is + # "<task>.status: <status>", so the daemon recovers the task and reads its mode. + local dir state fakebin sent capture notifylog fakenotify args url + dir=$(make_supercase notify-open-pr) + state="$dir/state" + fakebin="$dir/fakebin" + sent="$dir/sent.log"; : > "$sent" + capture="$dir/pane.txt"; : > "$capture" + notifylog="$dir/notify.log"; : > "$notifylog" + fakenotify="$dir/fake-notify.sh" + cat > "$fakenotify" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$notifylog" +exit 0 +SH + chmod +x "$fakenotify" + url="https://github.com/karotkriss/firstmate/pull/4" + + # non-local-only done: + PR URL -> --open is passed. + printf 'window=s:fm-fin-t7\nmode=no-mistakes\n' > "$state/fin-t7.meta" + escalate_add "$state" "fin-t7.status: done: PR $url checks green" + afk_enter "$state" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY='' \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" || fail "escalate_flush failed" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "--open" "a done: + PR escalation did not pass --open" + assert_contains "$args" "$url" "the PR URL was not passed to --open" + assert_not_contains "$args" "fin-t7" "task id leaked into the notifier args (summary stays id-free)" + + # local-only done: -> no --open (no PR). + : > "$notifylog" + printf 'window=s:fm-loc-t8\nmode=local-only\n' > "$state/loc-t8.meta" + escalate_add "$state" "loc-t8.status: done: ready in branch fm/loc-t8 https://github.com/o/r/pull/9" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY='' \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" || fail "second escalate_flush failed" + # Positive check first: a local-only done: must STILL fire the notifier (with + # --focus); otherwise the --open absence below would also pass on an empty log. + [ -s "$notifylog" ] || fail "local-only done: should still fire the notifier" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "--focus" "local-only done: should still request focus" + assert_not_contains "$args" "--open" "a local-only done: must not pass --open (no PR)" + pass "escalate_flush passes --open for a done: PR escalation, gated out for local-only" +} + +test_escalate_flush_decision_class_suppresses_open() { + # A digest classified as a DECISION wake (needs-decision/blocked/failed) must not + # carry --open even when a done: PR line is also buffered: the Open PR button is + # reserved for review/done-state toasts, so the action stays consistent with the + # notification class instead of pointing the captain at a different item. + local dir state fakebin sent capture notifylog fakenotify args url + dir=$(make_supercase notify-decision-no-open) + state="$dir/state" + fakebin="$dir/fakebin" + sent="$dir/sent.log"; : > "$sent" + capture="$dir/pane.txt"; : > "$capture" + notifylog="$dir/notify.log"; : > "$notifylog" + fakenotify="$dir/fake-notify.sh" + cat > "$fakenotify" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$notifylog" +exit 0 +SH + chmod +x "$fakenotify" + url="https://github.com/karotkriss/firstmate/pull/4" + + # A mixed buffer: one needs-decision item AND a non-local-only done: + PR URL. + printf 'window=s:fm-dec-t9\nmode=no-mistakes\n' > "$state/dec-t9.meta" + printf 'window=s:fm-don-t9\nmode=no-mistakes\n' > "$state/don-t9.meta" + escalate_add "$state" "dec-t9.status: needs-decision: pick A or B" + escalate_add "$state" "don-t9.status: done: PR $url checks green" + afk_enter "$state" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY='' \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" || fail "escalate_flush failed" + [ -s "$notifylog" ] || fail "a decision+done digest should still fire the notifier" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "--focus" "decision digest should still request focus" + assert_contains "$args" "need your decision" "mixed digest should classify as a decision wake" + assert_not_contains "$args" "--open" "a decision-class digest must not carry --open" + assert_not_contains "$args" "$url" "the PR URL must not ride a decision-class toast" + pass "escalate_flush suppresses --open when the digest is a decision wake" +} + +test_notify_off_gates_even_custom_cmd() { + # Security gate (CodeRabbit): FM_NOTIFY=off must silence the notifier even when + # FM_NOTIFY_CMD points at a custom command that would not consult the toggle + # itself. The off-check short-circuits BEFORE any command runs, so the override + # is never a bypass. A confirmed inject still happens; only the toast is gated. + local dir state fakebin sent capture notifylog fakenotify + dir=$(make_supercase notify-off-gate) + state="$dir/state" + fakebin="$dir/fakebin" + sent="$dir/sent.log"; : > "$sent" + capture="$dir/pane.txt"; : > "$capture" + notifylog="$dir/notify.log"; : > "$notifylog" + fakenotify="$dir/fake-notify.sh" + cat > "$fakenotify" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$notifylog" +exit 0 +SH + chmod +x "$fakenotify" + + escalate_add "$state" "fin-t6.status: needs-decision: pick A or B" + afk_enter "$state" + # FM_NOTIFY=off with a custom FM_NOTIFY_CMD: inject confirmed, notifier NOT run. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \ + FM_FAKE_TMUX_CAPTURE="$capture" FM_ESCALATE_BATCH_SECS=0 FM_NOTIFY=off \ + FM_NOTIFY_CMD="$fakenotify" escalate_flush "$state" || fail "escalate_flush failed under FM_NOTIFY=off" + [ -s "$sent" ] || fail "the escalation digest was not injected (the off-gate must not block delivery)" + [ -s "$notifylog" ] && fail "FM_NOTIFY=off still ran the custom FM_NOTIFY_CMD notifier" + pass "FM_NOTIFY=off gates the notifier even with a custom FM_NOTIFY_CMD, without blocking delivery" +} + test_escalate_batch_age_uses_first_append() { local dir state fakebin sent capture dir=$(make_supercase batch-age) @@ -692,6 +865,10 @@ test_stale_terminal_escalates test_housekeeping_persistent_stale_escalates test_housekeeping_resumed_stale_cleared test_escalate_batches_into_one_digest +test_escalate_flush_fires_notify +test_escalate_flush_passes_open_pr_url +test_escalate_flush_decision_class_suppresses_open +test_notify_off_gates_even_custom_cmd test_escalate_batch_age_uses_first_append test_heartbeat_scan_dedup test_handle_wake_routes_self_and_escalate diff --git a/tests/fm-focus.test.sh b/tests/fm-focus.test.sh new file mode 100755 index 000000000..0b5557d30 --- /dev/null +++ b/tests/fm-focus.test.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# tests/fm-focus.test.sh - bin/fm-focus.sh, the click-to-focus handler that the +# Windows "firstmate:" protocol runs when the captain clicks the notification. +# +# The real AppActivate (powershell.exe) only works on Windows, so it is stubbed to +# a no-op here and NEVER raises a window. What this suite pins is the part that +# must be correct on every host: the tmux window+pane is resolved DYNAMICALLY at +# click time with NOTHING hardcoded - no session name, window index, or pane +# literal. The proof is that focus lands correctly even when the session is NOT +# named "firstmate" (session-independence), via the recorded pane id and via the +# cwd-of-the-home fallback when that pane is gone. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +FOCUS="$ROOT/bin/fm-focus.sh" + +TMP_ROOT=$(fm_test_tmproot fm-focus-tests) +mkdir -p "$TMP_ROOT" + +# make_tmux_fakebin <dir> <log>: a fakebin whose `tmux` answers from env-provided +# fixtures and logs every call (NUL-delimited), plus a no-op powershell.exe so the +# host-raise step never touches a real Windows window. +# TMUX_STUB_PANES newline list of existing pane ids (for `list-panes -F '#{pane_id}'`) +# TMUX_STUB_FALLBACK newline list of "<id> <cmd> <path>" (for the 3-field list-panes) +# TMUX_STUB_WIN what display-message resolves '#{session_name}:#{window_index}' to +make_tmux_fakebin() { + local dir=$1 log=$2 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$log" +cmd=\$1; shift +case "\$cmd" in + list-panes) + fmt="" + while [ \$# -gt 0 ]; do [ "\$1" = "-F" ] && { fmt=\$2; break; }; shift; done + if [ "\$fmt" = '#{pane_id}' ]; then printf '%s\n' "\$TMUX_STUB_PANES" + else printf '%s\n' "\$TMUX_STUB_FALLBACK"; fi ;; + display-message) printf '%s\n' "\$TMUX_STUB_WIN" ;; + select-window|select-pane) : ;; +esac +exit 0 +SH + cat > "$fakebin/powershell.exe" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" "$fakebin/powershell.exe" + printf '%s\n' "$fakebin" +} + +# --- recorded pane id, session NOT named firstmate -------------------------- + +test_recorded_pane_resolves_nonfirstmate_session() { + local dir log fakebin pane_file calls + dir="$TMP_ROOT/recorded"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + pane_file="$dir/pane"; printf '%%5\n' > "$pane_file" + # The recorded pane %5 exists and lives in a session named "work" (not firstmate). + PATH="$fakebin:$PATH" FM_TMUX_PANE_FILE="$pane_file" \ + TMUX_STUB_PANES='%5' TMUX_STUB_WIN='work:3' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" || fail "fm-focus exited non-zero" + calls=$(tr '\0' '\n' < "$log") + # It must select the CURRENT session:window resolved for the pane, never a + # hardcoded "firstmate" target... + assert_contains "$calls" "select-window" "fm-focus did not select a window" + assert_contains "$calls" "work:3" "fm-focus did not target the pane's CURRENT session:window" + assert_not_contains "$calls" "firstmate:" "fm-focus used a hardcoded firstmate target" + # ...and select the recorded pane itself. + assert_contains "$calls" "select-pane" "fm-focus did not select the pane" + assert_contains "$calls" "%5" "fm-focus did not select the recorded pane id" + pass "fm-focus resolves a recorded pane in a non-firstmate session, nothing hardcoded" +} + +# --- fallback: recorded pane gone, locate claude pane by cwd of the home ------ + +test_fallback_finds_claude_pane_by_home_cwd() { + local dir log fakebin pane_file home calls fb + dir="$TMP_ROOT/fallback"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + home="$dir/home"; mkdir -p "$home" + # The recorded pane %99 no longer exists; only %5 (claude, cwd == home) and an + # unrelated %6 are live, again in a session not named firstmate. The 3-field + # list-panes output is TAB-delimited (matching fm-focus's awk -F '\t'). + pane_file="$dir/pane"; printf '%%99\n' > "$pane_file" + fb=$(printf '%%6\tbash\t/somewhere/else\n%%5\tclaude\t%s' "$home") + PATH="$fakebin:$PATH" FM_HOME="$home" FM_TMUX_PANE_FILE="$pane_file" \ + TMUX_STUB_PANES='%5 +%6' TMUX_STUB_WIN='ops:1' TMUX_STUB_FALLBACK="$fb" \ + bash "$FOCUS" || fail "fm-focus exited non-zero in the fallback path" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "%5" "fallback did not pick the claude pane whose cwd is the home" + assert_contains "$calls" "ops:1" "fallback did not resolve the pane's CURRENT session:window" + assert_not_contains "$calls" "%99" "fallback wrongly selected the stale recorded pane" + pass "fm-focus falls back to the claude pane whose cwd is the firstmate home" +} + +test_fallback_handles_home_with_spaces() { + # A firstmate home under a path with spaces must still match exactly: the + # tab-delimited list-panes parse means $3 is the WHOLE path, not its first word. + local dir log fakebin pane_file home calls fb + dir="$TMP_ROOT/fallback-spaces"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + home="$dir/My Projects/firstmate"; mkdir -p "$home" + pane_file="$dir/pane"; printf '%%99\n' > "$pane_file" + fb=$(printf '%%7\tclaude\t%s' "$home") + PATH="$fakebin:$PATH" FM_HOME="$home" FM_TMUX_PANE_FILE="$pane_file" \ + TMUX_STUB_PANES='%7' TMUX_STUB_WIN='work:2' TMUX_STUB_FALLBACK="$fb" \ + bash "$FOCUS" || fail "fm-focus exited non-zero with a spaced home" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "%7" "spaced-home fallback did not match the claude pane by full path" + assert_contains "$calls" "work:2" "spaced-home fallback did not resolve the current session:window" + pass "fm-focus fallback matches a firstmate home whose path contains spaces" +} + +# --- platform dispatch: macOS and Linux raise the host terminal, then select --- +# The terminal-raise tools (osascript activate, wmctrl/xdotool) are stubbed so +# nothing touches a real desktop; what is pinned is that each platform path raises +# the terminal AND then runs the SAME shared dynamic pane select. + +test_macos_raises_terminal_then_selects_pane() { + local dir log osa_log fakebin pane_file osa calls + dir="$TMP_ROOT/macos"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + osa_log="$dir/osa.log"; : > "$osa_log" + cat > "$fakebin/osascript" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$osa_log" +exit 0 +SH + chmod +x "$fakebin/osascript" + pane_file="$dir/pane"; printf '%%5\n' > "$pane_file" + PATH="$fakebin:$PATH" FM_NOTIFY_UNAME=Darwin FM_TMUX_PANE_FILE="$pane_file" \ + TMUX_STUB_PANES='%5' TMUX_STUB_WIN='work:3' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" || fail "fm-focus (macos) exited non-zero" + osa=$(tr '\0' '\n' < "$osa_log") + assert_contains "$osa" "activate" "macos focus did not activate a terminal app" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "select-pane" "macos focus did not select the pane" + assert_contains "$calls" "%5" "macos focus did not select the recorded pane id" + assert_contains "$calls" "work:3" "macos focus did not resolve the pane's CURRENT session:window" + pass "fm-focus on macOS activates the terminal then selects the firstmate pane" +} + +test_macos_explicit_term_override_is_activated() { + # FM_FOCUS_MACOS_TERM names the terminal app to activate directly (no detection). + local dir log osa_log fakebin pane_file osa + dir="$TMP_ROOT/macos-override"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + osa_log="$dir/osa.log"; : > "$osa_log" + cat > "$fakebin/osascript" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$osa_log" +exit 0 +SH + chmod +x "$fakebin/osascript" + pane_file="$dir/pane"; printf '%%5\n' > "$pane_file" + PATH="$fakebin:$PATH" FM_NOTIFY_UNAME=Darwin FM_FOCUS_MACOS_TERM='iTerm' \ + FM_TMUX_PANE_FILE="$pane_file" TMUX_STUB_PANES='%5' TMUX_STUB_WIN='work:3' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" || fail "fm-focus (macos override) exited non-zero" + osa=$(tr '\0' '\n' < "$osa_log") + assert_contains "$osa" 'tell application "iTerm" to activate' "macos override did not activate the named app" + pass "fm-focus on macOS activates the FM_FOCUS_MACOS_TERM app when set" +} + +test_linux_raises_window_then_selects_pane() { + local dir log wm_log fakebin pane_file pf wm calls + dir="$TMP_ROOT/linux"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + wm_log="$dir/wmctrl.log"; : > "$wm_log" + cat > "$fakebin/wmctrl" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$wm_log" +exit 0 +SH + chmod +x "$fakebin/wmctrl" + pane_file="$dir/pane"; printf '%%8\n' > "$pane_file" + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + PATH="$fakebin:$PATH" FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + FM_FOCUS_LINUX_WINDOW='firstmate-term' FM_TMUX_PANE_FILE="$pane_file" \ + TMUX_STUB_PANES='%8' TMUX_STUB_WIN='ops:2' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" || fail "fm-focus (linux) exited non-zero" + wm=$(tr '\0' '\n' < "$wm_log") + assert_contains "$wm" "firstmate-term" "linux focus did not raise the configured window via wmctrl" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "%8" "linux focus did not select the recorded pane id" + assert_contains "$calls" "ops:2" "linux focus did not resolve the pane's CURRENT session:window" + pass "fm-focus on Linux raises the window (wmctrl) then selects the firstmate pane" +} + +test_linux_degrades_to_select_when_no_window_found() { + # When the GUI raise finds no window (here: xdotool present but matching + # nothing, and no FM_FOCUS_LINUX_WINDOW), the raise is a clean no-op but the + # pane select STILL runs - the universally valuable half always happens. + local dir log fakebin pane_file pf calls + dir="$TMP_ROOT/linux-noraise"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + # xdotool that matches no window (prints nothing); wmctrl is not provided, and + # no FM_FOCUS_LINUX_WINDOW is set, so the raise degrades to a no-op. + cat > "$fakebin/xdotool" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/xdotool" + pane_file="$dir/pane"; printf '%%8\n' > "$pane_file" + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + PATH="$fakebin:$PATH" FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + FM_TMUX_PANE_FILE="$pane_file" TMUX_STUB_PANES='%8' TMUX_STUB_WIN='ops:2' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" || fail "fm-focus (linux no-raise) exited non-zero" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "%8" "linux focus did not select the pane when no window was found" + assert_contains "$calls" "select-window" "linux focus skipped the window select when the raise was a no-op" + pass "fm-focus on Linux still selects the pane when the window raise finds nothing" +} + +# --- positional home arg: the WSL launcher passes the home as $1 -------------- + +test_positional_home_arg_wins_over_env() { + # The WSL launcher passes the firstmate home as fm-focus.sh's positional $1, + # because an `env FM_HOME=...` wrapper does not survive WScript.Shell.Run. The + # arg must take precedence over any ambient FM_HOME, so the pane recorded under + # $1/state is the one selected even when FM_HOME points at a different home. + local dir log fakebin home decoy pf calls + dir="$TMP_ROOT/posarg"; mkdir -p "$dir" + log="$dir/tmux.log"; : > "$log" + fakebin=$(make_tmux_fakebin "$dir" "$log") + home="$dir/home"; mkdir -p "$home/state" + printf '%%4\n' > "$home/state/.fm-tmux-pane" + decoy="$dir/decoy"; mkdir -p "$decoy/state" + printf '%%9\n' > "$decoy/state/.fm-tmux-pane" + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + # FM_HOME points at the decoy and the positional arg names the real home; with no + # FM_TMUX_PANE_FILE, the pane file is derived from the resolved home, so selecting + # %4 (not the decoy's %9) proves $1 won. Both panes are live so the decoy would be + # selected if the env home were used. + PATH="$fakebin:$PATH" FM_HOME="$decoy" FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + TMUX_STUB_PANES=$'%4\n%9' TMUX_STUB_WIN='work:7' TMUX_STUB_FALLBACK='' \ + bash "$FOCUS" "$home" || fail "fm-focus exited non-zero with a positional home arg" + calls=$(tr '\0' '\n' < "$log") + assert_contains "$calls" "%4" "positional home arg did not win over FM_HOME env" + assert_not_contains "$calls" "%9" "fm-focus used the FM_HOME env home instead of the positional arg" + assert_contains "$calls" "work:7" "positional home did not resolve the pane's CURRENT session:window" + pass "fm-focus's positional \$1 home wins over the FM_HOME env (WScript.Shell.Run-safe)" +} + +test_recorded_pane_resolves_nonfirstmate_session +test_fallback_finds_claude_pane_by_home_cwd +test_fallback_handles_home_with_spaces +test_macos_raises_terminal_then_selects_pane +test_macos_explicit_term_override_is_activated +test_linux_raises_window_then_selects_pane +test_linux_degrades_to_select_when_no_window_found +test_positional_home_arg_wins_over_env diff --git a/tests/fm-notify.test.sh b/tests/fm-notify.test.sh new file mode 100755 index 000000000..091a64ec5 --- /dev/null +++ b/tests/fm-notify.test.sh @@ -0,0 +1,625 @@ +#!/usr/bin/env bash +# tests/fm-notify.test.sh - the platform-portable logic of bin/fm-notify.sh, the +# native out-of-band notifier (firstmate issue #106). +# +# The real toast (WinRT via powershell.exe) and the click-to-focus protocol only +# work on Windows, so they are guarded behind platform detection and NEVER run +# here: this suite pins the pure logic that runs on Linux CI - platform detection +# from a mocked uname/proc, the FM_NOTIFY=off short-circuit, argument handling, +# backend routing (with the host tools stubbed), the persistent scenario="reminder" +# toast and its click-to-focus action wiring, the pane-id recording and the +# "firstmate:" protocol install (with reg.exe/cmd.exe/wslpath/tmux stubbed), and +# the base64 carrying of title/message that keeps arbitrary text from injecting +# into the generated PowerShell. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +NOTIFY="$ROOT/bin/fm-notify.sh" +# Source the pure helpers (main runs only under the BASH_SOURCE guard when executed). +if [ -z "${FM_NOTIFY_TEST_SOURCED:-}" ]; then + export FM_NOTIFY_TEST_SOURCED=1 + # shellcheck source=bin/fm-notify.sh + . "$NOTIFY" +fi + +TMP_ROOT=$(fm_test_tmproot fm-notify-tests) +mkdir -p "$TMP_ROOT" + +# A fakebin whose powershell.exe / notify-send / osascript log every arg +# (NUL-delimited) so a test can inspect exactly what was dispatched. +make_notify_fakebin() { + local dir=$1 log=$2 fakebin tool + fakebin=$(fm_fakebin "$dir") + for tool in powershell.exe notify-send osascript; do + cat > "$fakebin/$tool" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$log" +exit 0 +SH + chmod +x "$fakebin/$tool" + done + printf '%s\n' "$fakebin" +} + +# A fakebin with terminal-notifier + osascript that log every arg. Used by the +# macOS-mirror tests so terminal-notifier is "present". +make_macos_fakebin() { + local dir=$1 log=$2 fakebin tool + fakebin=$(fm_fakebin "$dir") + for tool in terminal-notifier osascript; do + cat > "$fakebin/$tool" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$log" +exit 0 +SH + chmod +x "$fakebin/$tool" + done + printf '%s\n' "$fakebin" +} + +# A fakebin whose notify-send advertises (or not) --action in its --help, and +# logs every real call's args (NUL-delimited) to $NS_LOG. NS_SUPPORTS_ACTION=1 +# in the environment makes --help mention --action so the click-to-focus path is +# taken; absent/0 makes it plain. So one stub serves both Linux variants. +make_linux_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/notify-send" <<'SH' +#!/usr/bin/env bash +if [ "$1" = "--help" ]; then + echo "Usage: notify-send [OPTION...] <SUMMARY> [BODY]" + [ "${NS_SUPPORTS_ACTION:-0}" = 1 ] && echo " -A, --action=[NAME=]Text Specifies an action" + exit 0 +fi +printf '%s\0' "$@" >> "$NS_LOG" +exit 0 +SH + chmod +x "$fakebin/notify-send" + printf '%s\n' "$fakebin" +} + +# Portable base64 codec so this suite runs on BSD/macOS too, not just GNU/Linux: +# GNU uses `-d`/`-w0`, BSD uses `-D` and wraps differently. Decode tries `-d` then +# falls back to `-D`; encode strips newlines after a flagless base64. +b64_decode() { local d; d=$(cat); printf '%s' "$d" | base64 -d 2>/dev/null || printf '%s' "$d" | base64 -D 2>/dev/null; } +b64_encode_oneline() { base64 2>/dev/null | tr -d '\n'; } + +# Decode fm-notify's PowerShell -EncodedCommand back to UTF-8 text. The arg after +# -EncodedCommand in the NUL-delimited log is base64 of the UTF-16LE script. +decode_encoded_command() { + local log=$1 enc + enc=$(tr '\0' '\n' < "$log" | awk 'p{print;exit} /^-EncodedCommand$/{p=1}') + printf '%s' "$enc" | b64_decode | iconv -f UTF-16LE -t UTF-8 2>/dev/null +} + +# --- platform detection ----------------------------------------------------- + +test_platform_detection() { + local pf + pf="$TMP_ROOT/proc-wsl"; printf 'Linux version 6.6-microsoft-standard-WSL2\n' > "$pf" + [ "$(FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" fm_detect_platform)" = wsl ] \ + || fail "WSL (microsoft in /proc/version) not detected as wsl" + pf="$TMP_ROOT/proc-linux"; printf 'Linux version 6.6 generic\n' > "$pf" + [ "$(FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" fm_detect_platform)" = linux ] \ + || fail "native Linux misdetected" + [ "$(FM_NOTIFY_UNAME=Darwin fm_detect_platform)" = macos ] || fail "Darwin not macos" + [ "$(FM_NOTIFY_UNAME=MINGW64_NT-10.0 fm_detect_platform)" = windows ] || fail "MINGW not windows" + [ "$(FM_NOTIFY_UNAME=CYGWIN_NT-10.0 fm_detect_platform)" = windows ] || fail "CYGWIN not windows" + [ "$(FM_NOTIFY_UNAME=Plan9 FM_NOTIFY_PROC_VERSION="$TMP_ROOT/none" fm_detect_platform)" = unknown ] \ + || fail "an unrecognized platform was not 'unknown'" + pass "fm_detect_platform: wsl before linux, plus windows/macos/unknown" +} + +# --- FM_NOTIFY toggle ------------------------------------------------------- + +test_enabled_toggle() { + local v + for v in '' on On weird; do + FM_NOTIFY="$v" fm_notify_enabled || fail "FM_NOTIFY='$v' should be enabled" + done + for v in off OFF 0 false no disable disabled; do + FM_NOTIFY="$v" fm_notify_enabled && fail "FM_NOTIFY='$v' should be disabled" + done + pass "fm_notify_enabled: on by default, disabled by off/0/false/no/disable/disabled" +} + +# --- argument handling ------------------------------------------------------ + +test_missing_title_usage() { + local out rc + out=$(bash "$NOTIFY" 2>&1); rc=$? + expect_code 2 "$rc" "no args should exit 2" + assert_contains "$out" "usage: fm-notify.sh" "missing-title did not print usage" + pass "fm-notify: missing title prints usage and exits 2" +} + +test_missing_message_usage() { + # The documented usage requires BOTH a title and a message; a title-only call is + # an incomplete invocation and must fail fast instead of looking successful. + local out rc + out=$(bash "$NOTIFY" "only a title" 2>&1); rc=$? + expect_code 2 "$rc" "title-only should exit 2" + assert_contains "$out" "usage: fm-notify.sh" "missing-message did not print usage" + pass "fm-notify: a title with no message prints usage and exits 2" +} + +test_open_without_url_rejected() { + # --open with no following URL is a miswired call: reject it (exit 2 + usage) + # rather than silently dropping the button and reporting success. + local out rc + out=$(bash "$NOTIFY" "Firstmate" "ready" --open 2>&1); rc=$? + expect_code 2 "$rc" "--open with no URL should exit 2" + assert_contains "$out" "usage: fm-notify.sh" "--open with no URL did not print usage" + # The --open=<empty> form is rejected the same way. + out=$(bash "$NOTIFY" "Firstmate" "ready" --open= 2>&1); rc=$? + expect_code 2 "$rc" "--open= (empty) should exit 2" + pass "fm-notify: --open with no URL is rejected (exit 2), not silently dropped" +} + +test_off_short_circuits_dispatch() { + local dir log fakebin pf + dir="$TMP_ROOT/off"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + PATH="$fakebin:$PATH" FM_NOTIFY=off FM_NOTIFY_BG=0 \ + FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + bash "$NOTIFY" "Firstmate" "should not fire" --focus || fail "off-mode exited non-zero" + [ -s "$log" ] && fail "FM_NOTIFY=off still dispatched a backend" + pass "FM_NOTIFY=off short-circuits all dispatch (exit 0, nothing sent)" +} + +# --- backend routing (host tools stubbed) ----------------------------------- + +test_linux_persistent_plain_when_no_action_support() { + # A notify-send WITHOUT --action support still gets the persistent (critical) + # mirror with title + message; the click-to-focus path is skipped, never fatal. + local dir fakebin log pf args + dir="$TMP_ROOT/linux-plain"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_linux_fakebin "$dir") + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + NS_LOG="$log" NS_SUPPORTS_ACTION=0 \ + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + bash "$NOTIFY" "Firstmate" "ready for review" || fail "linux dispatch exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "--urgency=critical" "notify-send missing the persistent (critical) urgency" + assert_contains "$args" "Firstmate" "notify-send missing title" + assert_contains "$args" "ready for review" "notify-send missing message" + assert_not_contains "$args" "--action" "no-action daemon must not get a --action click path" + pass "linux mirrors persistent (critical) notify-send and degrades without --action support" +} + +test_linux_action_when_supported() { + # When notify-send advertises --action, the default (focus-on) call wires the + # "Go to firstmate" click-to-focus action on the persistent (critical) toast. + local dir fakebin log pf args + dir="$TMP_ROOT/linux-action"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_linux_fakebin "$dir") + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + NS_LOG="$log" NS_SUPPORTS_ACTION=1 \ + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + bash "$NOTIFY" "Firstmate" "a decision is needed" || fail "linux action dispatch exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "--urgency=critical" "action path dropped the persistent urgency" + assert_contains "$args" "--action=focus=Go to firstmate" "action path missing the focus action" + assert_contains "$args" "--wait" "action path must --wait for the click" + pass "linux adds the click-to-focus action when the notify-send daemon supports it" +} + +test_macos_osascript_fallback_has_sound() { + # With terminal-notifier ABSENT, fall back to osascript display notification - + # now WITH a sound (still better than a silent banner). + # + # This is the one notifier test whose path depends on a host tool being ABSENT + # rather than stubbed present: fm_notify_macos probes terminal-notifier first, + # so the osascript fallback is only reached on a macOS host with no + # terminal-notifier. Gate it to exactly that, via fm_detect_platform, and skip + # cleanly elsewhere. A non-macOS host (the Linux CI runner) would never take the + # macOS branch, and a host that ships terminal-notifier (some runners carry it + # as a Ruby gem binstub, and a macOS box may have it installed) would take the + # terminal-notifier branch instead, leaving osascript uncalled. The skip still + # emits one passing line so the harness count and format stay consistent. + if [ "$(fm_detect_platform)" != macos ] || command -v terminal-notifier >/dev/null 2>&1; then + pass "macos falls back to osascript display notification WITH a sound (skipped: needs a macOS host with no terminal-notifier)" + return 0 + fi + local dir log fakebin args + dir="$TMP_ROOT/macos-fallback"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") # no terminal-notifier here + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Darwin \ + bash "$NOTIFY" "Firstmate" "a decision is needed" || fail "macos fallback exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "display notification" "osascript missing the notification verb" + assert_contains "$args" "a decision is needed" "osascript missing message" + assert_contains "$args" "sound name" "osascript fallback must request a sound" + pass "macos falls back to osascript display notification WITH a sound" +} + +test_macos_terminal_notifier_persistent_sound_and_execute() { + # With terminal-notifier PRESENT, the default (focus-on) call mirrors Windows: + # a sound and an -execute click-to-focus that runs fm-focus.sh with FM_HOME. + local dir log fakebin home args + dir="$TMP_ROOT/macos-tn"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_macos_fakebin "$dir" "$log") + home="$dir/home"; mkdir -p "$home" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Darwin \ + bash "$NOTIFY" "Firstmate" "a decision is needed" || fail "macos terminal-notifier exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "-sound" "terminal-notifier missing the sound flag" + assert_contains "$args" "-execute" "terminal-notifier missing the click-to-focus -execute" + assert_contains "$args" "fm-focus.sh" "-execute does not run the focus handler" + assert_contains "$args" "FM_HOME=" "-execute does not pass the firstmate home" + pass "macos uses terminal-notifier with sound + -execute click-to-focus when present" +} + +test_macos_terminal_notifier_no_focus_omits_execute() { + # --no-focus opts out of the click action even when terminal-notifier is present; + # the sounding notification still fires. + local dir log fakebin args + dir="$TMP_ROOT/macos-tn-nofocus"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_macos_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Darwin \ + bash "$NOTIFY" "Firstmate" "fyi" --no-focus || fail "macos --no-focus exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "-sound" "terminal-notifier dropped the sound under --no-focus" + assert_not_contains "$args" "-execute" "--no-focus must omit the -execute click action" + pass "macos --no-focus keeps the sound but omits the click-to-focus -execute" +} + +test_windows_bare_call_defaults_to_focus_sound_persist() { + # The captain's bug: a toast fired WITHOUT the focus action dropped silently + # into the notification center. A bare `fm-notify.sh "T" "M"` (no flags) must + # now ALWAYS produce the full toast: persistent reminder, an explicit <audio> so + # it sounds, and the click-to-focus action - never the silent-drop form. + local dir log fakebin ps + dir="$TMP_ROOT/win-bare"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "needs your decision" || fail "windows bare dispatch exited non-zero" + ps=$(decode_encoded_command "$log") + assert_contains "$ps" "ToastNotificationManager" "windows path did not build a toast" + assert_contains "$ps" 'scenario="reminder"' "bare toast is not the persistent reminder scenario" + assert_contains "$ps" "ToastGeneric" "toast is not ToastGeneric" + assert_contains "$ps" '<audio ' "bare toast carries no explicit audio - it could drop silently" + assert_contains "$ps" 'arguments="firstmate:focus"' "bare toast missing the default click-to-focus action" + assert_contains "$ps" 'launch="firstmate:focus"' "bare toast missing the body launch" + assert_contains "$ps" "Go to firstmate" "bare toast missing the focus action button" + # Still click-to-focus only: never an auto-foreground. + assert_not_contains "$ps" "AppActivate" "the toast must never auto-foreground (click-to-focus only)" + pass "windows bare call defaults to persistent + sounding + click-to-focus (the silent-drop fix)" +} + +test_windows_no_focus_flag_omits_click_action_but_keeps_sound() { + # --no-focus (tests only) drops the focus action but the toast STILL persists + # and sounds - never the silent-drop form. + local dir log fakebin ps + dir="$TMP_ROOT/win-noinfocus"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "no focus here" --no-focus || fail "windows --no-focus exited non-zero" + ps=$(decode_encoded_command "$log") + assert_contains "$ps" "ToastNotificationManager" "windows path did not build a toast" + assert_contains "$ps" 'scenario="reminder"' "toast is not the persistent reminder scenario" + assert_contains "$ps" "ToastGeneric" "toast is not ToastGeneric" + assert_contains "$ps" '<audio ' "even --no-focus toast must still sound (explicit audio)" + # --no-focus: no click-to-focus action and never an auto-focus AppActivate. + assert_not_contains "$ps" "firstmate:focus" "--no-focus must not carry the focus action" + assert_not_contains "$ps" "AppActivate" "the toast must never auto-foreground (click-to-focus only)" + pass "windows --no-focus omits the focus action but keeps a persistent, sounding toast" +} + +test_windows_focus_adds_click_to_focus_action_not_appactivate() { + local dir log fakebin ps + dir="$TMP_ROOT/win-focus"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "decision needed" --focus || fail "windows+focus exited non-zero" + ps=$(decode_encoded_command "$log") + # --focus wires the click-to-focus protocol action + body launch + sound... + assert_contains "$ps" 'scenario="reminder"' "focus toast is not the persistent reminder scenario" + assert_contains "$ps" '<audio ' "focus toast carries no explicit audio" + assert_contains "$ps" 'arguments="firstmate:focus"' "--focus did not add the firstmate: protocol action" + assert_contains "$ps" 'launch="firstmate:focus"' "--focus did not wire the toast body launch" + assert_contains "$ps" "Go to firstmate" "--focus did not add the focus action button" + # ...but NEVER auto-focuses. Auto-focus was rejected as invasive. + assert_not_contains "$ps" "AppActivate" "--focus must not auto-foreground; focus happens only on click" + pass "windows --focus adds the click-to-focus protocol action, never an auto AppActivate" +} + +test_windows_open_adds_open_pr_action_button() { + # --open <url> adds a SECOND toast action, "Open PR", that opens the URL in the + # default browser (activationType="protocol"). The "Go to firstmate" focus action + # and the Dismiss action are still present, so the toast carries both buttons. + local dir log fakebin ps url + dir="$TMP_ROOT/win-open"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + url="https://github.com/karotkriss/firstmate/pull/4" + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "1 item ready for review" --focus --open "$url" \ + || fail "windows --open exited non-zero" + ps=$(decode_encoded_command "$log") + assert_contains "$ps" 'content="Open PR"' "--open did not add the Open PR action" + assert_contains "$ps" "arguments=\"$url\"" "Open PR action did not carry the PR URL" + assert_contains "$ps" 'activationType="protocol"' "Open PR action is not a protocol activation" + assert_contains "$ps" "Go to firstmate" "--open dropped the focus action button" + assert_contains "$ps" "Dismiss" "--open dropped the Dismiss action" + pass "windows --open adds the Open PR browser action alongside focus + dismiss" +} + +test_windows_no_open_omits_open_pr_action() { + # Without --open the toast is unchanged: no Open PR button (backward compatible). + local dir log fakebin ps + dir="$TMP_ROOT/win-noopen"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "a decision is needed" --focus || fail "windows bare exited non-zero" + ps=$(decode_encoded_command "$log") + assert_not_contains "$ps" "Open PR" "a bare call must not carry an Open PR button" + pass "windows omits the Open PR action when --open is not given (unchanged behavior)" +} + +test_windows_open_url_is_xml_attribute_escaped() { + # The Open PR URL is interpolated into an XML attribute, so a value with XML + # metacharacters must be entity-escaped, never able to break the toast markup. + local dir log fakebin ps + dir="$TMP_ROOT/win-open-escape"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "Firstmate" "ready" --open 'https://x/pull/1?a=1&b="2"' || fail "windows --open escape exited non-zero" + ps=$(decode_encoded_command "$log") + assert_contains "$ps" '&' "ampersand in the URL was not XML-escaped" + assert_contains "$ps" '"' "double-quote in the URL was not XML-escaped" + assert_not_contains "$ps" 'arguments="https://x/pull/1?a=1&b="2""' "raw unescaped URL broke the attribute" + pass "windows --open XML-escapes the URL so it can never break the toast attribute" +} + +test_macos_open_uses_terminal_notifier_open_flag() { + # macOS terminal-notifier has one click action: with --open the click OPENS the + # PR (-open <url>) and the focus -execute is omitted (best-effort macOS parity). + local dir log fakebin args + dir="$TMP_ROOT/macos-open"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_macos_fakebin "$dir" "$log") + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Darwin \ + bash "$NOTIFY" "Firstmate" "ready for review" --focus --open "https://github.com/o/r/pull/9" \ + || fail "macos --open exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "-open" "terminal-notifier missing the -open click action" + assert_contains "$args" "https://github.com/o/r/pull/9" "-open did not carry the PR URL" + assert_contains "$args" "-sound" "terminal-notifier dropped the sound under --open" + assert_not_contains "$args" "-execute" "--open must use -open, not the focus -execute (one click action)" + pass "macos --open opens the PR via terminal-notifier -open (best-effort parity)" +} + +test_linux_open_adds_openpr_action_when_supported() { + # On a notify-send that supports actions, --open adds a SECOND action, "Open PR", + # alongside the focus action, so the captain can open the PR from the toast. + local dir fakebin log pf args + dir="$TMP_ROOT/linux-open"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_linux_fakebin "$dir") + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + NS_LOG="$log" NS_SUPPORTS_ACTION=1 \ + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + bash "$NOTIFY" "Firstmate" "ready for review" --focus --open "https://github.com/o/r/pull/12" \ + || fail "linux --open dispatch exited non-zero" + args=$(tr '\0' '\n' < "$log") + assert_contains "$args" "--action=focus=Go to firstmate" "--open dropped the focus action" + assert_contains "$args" "--action=openpr=Open PR" "--open did not add the Open PR action" + assert_contains "$args" "--wait" "the multi-action notify-send must --wait for a click" + pass "linux --open adds the Open PR action alongside focus when the daemon supports actions" +} + +test_title_message_carried_as_base64_not_injected() { + # A title/message with PowerShell-dangerous characters must be carried as + # base64 data, never interpolated as code. The decoded script must contain the + # base64 of the text but NOT the raw dangerous substring. + local dir log fakebin ps title msg tb mb + dir="$TMP_ROOT/inject"; mkdir -p "$dir" + log="$dir/calls.log"; : > "$log" + fakebin=$(make_notify_fakebin "$dir" "$log") + title="Firstmate" + msg="'@; Remove-Item C:\\ -Recurse \$(whoami)" + PATH="$fakebin:$PATH" FM_NOTIFY=on FM_NOTIFY_BG=0 FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + bash "$NOTIFY" "$title" "$msg" || fail "windows inject-case exited non-zero" + ps=$(decode_encoded_command "$log") + tb=$(printf '%s' "$title" | iconv -f UTF-8 -t UTF-16LE | b64_encode_oneline) + mb=$(printf '%s' "$msg" | iconv -f UTF-8 -t UTF-16LE | b64_encode_oneline) + assert_contains "$ps" "FromBase64String('$tb')" "title not carried as base64" + assert_contains "$ps" "FromBase64String('$mb')" "message not carried as base64" + assert_not_contains "$ps" "Remove-Item C:\\ -Recurse" "raw dangerous text leaked into the script" + pass "title/message are base64-carried, not injectable into the PowerShell" +} + +# --- click-to-focus arming: pane-id recording + protocol install ------------ + +test_record_pane_writes_pane_file() { + # firstmate records its OWN pane id to the FM_HOME-derived path fm-focus.sh + # reads. The path is never hardcoded: it follows FM_HOME. + local dir fakebin home out pane_file + dir="$TMP_ROOT/record-pane"; mkdir -p "$dir" + home="$dir/home"; mkdir -p "$home/state" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%%7\n' +exit 0 +SH + chmod +x "$fakebin/tmux" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" fm_notify_record_pane) + pane_file="$home/state/.fm-tmux-pane" + [ "$out" = "$pane_file" ] || fail "record_pane did not echo the FM_HOME-derived path: '$out'" + assert_grep '%7' "$pane_file" "recorded pane id not written to the derived file" + pass "fm_notify_record_pane writes the pane id under the FM_HOME-derived path" +} + +test_install_skips_protocol_off_wsl() { + # Off WSL: the pane id is still recorded, but the protocol install is a clean + # no-op (no reg.exe), never an error - the launcher shells through wsl.exe. + local dir fakebin home pf out + dir="$TMP_ROOT/install-linux"; mkdir -p "$dir" + home="$dir/home"; mkdir -p "$home/state" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%%3\n' +exit 0 +SH + chmod +x "$fakebin/tmux" + pf="$dir/proc"; printf 'Linux generic\n' > "$pf" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" \ + fm_notify_install) || fail "install exited non-zero off WSL" + assert_contains "$out" "recorded firstmate pane id" "install did not record the pane id" + assert_contains "$out" "skipped (WSL only)" "install did not skip the protocol off WSL" + assert_grep '%3' "$home/state/.fm-tmux-pane" "pane file not written by install" + pass "fm_notify_install records the pane and skips the protocol off WSL" +} + +test_install_skips_protocol_on_native_windows() { + # Native Windows (MINGW/Cygwin) is NOT WSL: the launcher needs wsl.exe, so the + # protocol install must skip cleanly there too instead of writing a broken one. + local dir fakebin home out + dir="$TMP_ROOT/install-winnative"; mkdir -p "$dir" + home="$dir/home"; mkdir -p "$home/state" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%%4\n' +exit 0 +SH + chmod +x "$fakebin/tmux" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_NOTIFY_UNAME=MINGW64_NT-10.0 \ + fm_notify_install) || fail "install exited non-zero on native Windows" + assert_contains "$out" "windows - click-to-focus protocol install skipped (WSL only)" \ + "native Windows did not skip the WSL-only protocol install" + assert_grep '%4' "$home/state/.fm-tmux-pane" "pane file not written by install on native Windows" + pass "fm_notify_install skips the WSL-only protocol install on native Windows" +} + +test_install_registers_protocol_on_wsl() { + # On WSL (microsoft in /proc/version): register the firstmate: protocol under + # HKCU and write the hidden VBS launcher pointing at the installed fm-focus.sh. + # reg.exe / cmd.exe / wslpath / tmux are stubbed so nothing touches a real host. + local dir fakebin home pf reglog out reg vbs focus_sh + dir="$TMP_ROOT/install-wsl"; mkdir -p "$dir/appdata" + home="$dir/home"; mkdir -p "$home/state" + fakebin=$(fm_fakebin "$dir") + reglog="$dir/reg.log"; : > "$reglog" + pf="$dir/proc"; printf 'Linux version 6.6-microsoft-standard-WSL2\n' > "$pf" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%%9\n' +exit 0 +SH + cat > "$fakebin/cmd.exe" <<'SH' +#!/usr/bin/env bash +printf 'C:\\Users\\test\\AppData\\Local\r\n' +exit 0 +SH + # -u <winpath> -> our scratch appdata dir; -w <wslpath> -> a fixed Windows path. + cat > "$fakebin/wslpath" <<'SH' +#!/usr/bin/env bash +if [ "$1" = "-u" ]; then printf '%s\n' "$FAKE_APPDATA"; else printf '%s\n' 'C:\fake\fm-launch.vbs'; fi +SH + cat > "$fakebin/reg.exe" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$reglog" +exit 0 +SH + chmod +x "$fakebin/tmux" "$fakebin/cmd.exe" "$fakebin/wslpath" "$fakebin/reg.exe" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" WSL_DISTRO_NAME=TestDistro FAKE_APPDATA="$dir/appdata" \ + FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" fm_notify_install) \ + || fail "install exited non-zero on WSL" + assert_contains "$out" "click-to-focus armed" "install did not report arming" + reg=$(tr '\0' '\n' < "$reglog") + assert_contains "$reg" 'HKCU\Software\Classes\firstmate\shell\open\command' "protocol command key not registered" + assert_contains "$reg" 'wscript.exe //B //Nologo "C:\fake\fm-launch.vbs" "%1"' "open command not wired to the launcher" + vbs=$(cat "$dir/appdata/firstmate/fm-launch.vbs") + focus_sh="$ROOT/bin/fm-focus.sh" + assert_contains "$vbs" 'WScript.Shell' "launcher is not a WScript.Shell .Run" + # WScript.Shell.Run does not preserve quotes around the distro or an `env VAR=` + # wrapper, so the distro is pinned UNQUOTED and the home rides as a positional arg. + assert_contains "$vbs" 'wsl.exe -d TestDistro -e bash' "launcher does not pin the distro unquoted" + assert_not_contains "$vbs" 'env FM_HOME=' "launcher still wraps the home in an env that WScript.Shell.Run drops" + assert_contains "$vbs" ', 0, False' "launcher does not run hidden (window style 0)" + # The home that armed the pane file is passed as fm-focus.sh's positional $1, and + # both the focus path and the home are VBS-quoted ("") so spaces survive as one arg. + assert_contains "$vbs" "bash \"\"$focus_sh\"\" \"\"$home\"\"" "launcher does not pass the recorded home as fm-focus.sh's positional arg" + pass "fm_notify_install registers the firstmate: protocol + hidden launcher (positional-home, distro-pinned) on WSL" +} + +test_install_fails_when_any_registry_write_fails() { + # Every reg.exe write is checked, not just the last: if an EARLIER write fails, + # install must fail (non-zero, no "armed") instead of falsely reporting success. + local dir fakebin home pf out rc + dir="$TMP_ROOT/install-regfail"; mkdir -p "$dir/appdata" + home="$dir/home"; mkdir -p "$home/state" + fakebin=$(fm_fakebin "$dir") + pf="$dir/proc"; printf 'Linux version 6.6-microsoft-standard-WSL2\n' > "$pf" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf '%%9\n' +exit 0 +SH + cat > "$fakebin/cmd.exe" <<'SH' +#!/usr/bin/env bash +printf 'C:\\Users\\test\\AppData\\Local\r\n' +exit 0 +SH + cat > "$fakebin/wslpath" <<'SH' +#!/usr/bin/env bash +if [ "$1" = "-u" ]; then printf '%s\n' "$FAKE_APPDATA"; else printf '%s\n' 'C:\fake\fm-launch.vbs'; fi +SH + # reg.exe fails ONLY on the first write (the URL:firstmate Protocol default value). + cat > "$fakebin/reg.exe" <<'SH' +#!/usr/bin/env bash +for a in "$@"; do [ "$a" = "URL:firstmate Protocol" ] && exit 1; done +exit 0 +SH + chmod +x "$fakebin/tmux" "$fakebin/cmd.exe" "$fakebin/wslpath" "$fakebin/reg.exe" + out=$(PATH="$fakebin:$PATH" FM_HOME="$home" WSL_DISTRO_NAME=TestDistro FAKE_APPDATA="$dir/appdata" \ + FM_NOTIFY_UNAME=Linux FM_NOTIFY_PROC_VERSION="$pf" fm_notify_install 2>&1); rc=$? + [ "$rc" -ne 0 ] || fail "install returned 0 despite an early registry write failing" + assert_not_contains "$out" "click-to-focus armed" "install falsely reported armed after a registry write failed" + assert_contains "$out" "reg.exe failed" "install did not report the registry failure" + pass "fm_notify_install fails when ANY registry write fails, not just the last" +} + +test_platform_detection +test_enabled_toggle +test_missing_title_usage +test_missing_message_usage +test_open_without_url_rejected +test_off_short_circuits_dispatch +test_linux_persistent_plain_when_no_action_support +test_linux_action_when_supported +test_macos_osascript_fallback_has_sound +test_macos_terminal_notifier_persistent_sound_and_execute +test_macos_terminal_notifier_no_focus_omits_execute +test_windows_bare_call_defaults_to_focus_sound_persist +test_windows_no_focus_flag_omits_click_action_but_keeps_sound +test_windows_focus_adds_click_to_focus_action_not_appactivate +test_windows_open_adds_open_pr_action_button +test_windows_no_open_omits_open_pr_action +test_windows_open_url_is_xml_attribute_escaped +test_macos_open_uses_terminal_notifier_open_flag +test_linux_open_adds_openpr_action_when_supported +test_title_message_carried_as_base64_not_injected +test_record_pane_writes_pane_file +test_install_skips_protocol_off_wsl +test_install_skips_protocol_on_native_windows +test_install_registers_protocol_on_wsl +test_install_fails_when_any_registry_write_fails diff --git a/tests/fm-wake-daemon-lifecycle-e2e.test.sh b/tests/fm-wake-daemon-lifecycle-e2e.test.sh index e027dca96..023f19e3e 100755 --- a/tests/fm-wake-daemon-lifecycle-e2e.test.sh +++ b/tests/fm-wake-daemon-lifecycle-e2e.test.sh @@ -24,6 +24,10 @@ WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" DAEMON="$ROOT/bin/fm-supervise-daemon.sh" +# Keep this e2e from firing real OS notifications when the daemon flushes an +# escalation (escalate_flush -> notify_escalation -> bin/fm-notify.sh). +export FM_NOTIFY=off + # Source the daemon's pure functions (its main loop is guarded out under sourcing). if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then export FM_TEST_DAEMON_SOURCED=1 diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index ac87e6daa..043a1a289 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -14,6 +14,11 @@ set -u WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" +# Keep this suite from firing real OS notifications: it drives the real +# bin/fm-watch.sh over captain-relevant statuses, whose signal path execs +# bin/fm-notify.sh (notify_signal_statuses). Matches the sibling e2e suites. +export FM_NOTIFY=off + TMP_ROOT=$(fm_test_tmproot fm-wake-tests) diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 54faeae9f..35535e7c8 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -34,8 +34,12 @@ TMP_ROOT=$(fm_test_tmproot fm-watch-triage-tests) watch_bg() { # <state> <fakebin> <out> [extra env assignments...] local state=$1 fakebin=$2 out=$3 shift 3 + # Extra "VAR=val" args are applied via `env` so they are real assignments: a + # post-expansion "$@" token is parsed as a command name, not an assignment, so + # the inline-prefix form would run "FM_NOTIFY=on" as a command. env handles both + # the no-extra case (env just execs the watcher) and any passed assignments. PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ - FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$@" "$WATCH" > "$out" & + FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 env "$@" "$WATCH" > "$out" & } # Wait up to <limit> 0.1s ticks while <pid> stays alive; 0 if still alive, 1 if it died. @@ -93,6 +97,38 @@ test_signal_reason_is_actionable_classifier() { pass "signal_reason_is_actionable: benign absorbed, captain verbs and coalesced batches surfaced" } +test_status_pr_url_extractor() { + # fm_status_pr_url pulls the GitHub PR / GitLab MR URL out of a done-state status + # line (the "Open PR" button target), stopping at the trailing number so status + # punctuation is excluded, and prints nothing when there is no PR/MR URL. + [ "$(fm_status_pr_url 'done: PR https://github.com/o/r/pull/7 checks green')" = 'https://github.com/o/r/pull/7' ] \ + || fail "GitHub /pull/ URL not extracted" + [ "$(fm_status_pr_url 'done: ready https://gitlab.com/o/r/-/merge_requests/45, ok')" = 'https://gitlab.com/o/r/-/merge_requests/45' ] \ + || fail "GitLab /merge_requests/ URL not extracted (or trailing comma included)" + [ "$(fm_status_pr_url 'done: opened https://github.com/o/r/pull/5.')" = 'https://github.com/o/r/pull/5' ] \ + || fail "trailing period leaked into the extracted URL" + [ -z "$(fm_status_pr_url 'needs-decision: pick A or B')" ] || fail "a non-PR line yielded a URL" + [ -z "$(fm_status_pr_url 'done: PR https://example.test/pr/7 checks green')" ] \ + || fail "a non-pull/-MR path (/pr/) was wrongly treated as a PR URL" + pass "fm_status_pr_url extracts GitHub PR / GitLab MR URLs and ignores the rest" +} + +test_task_is_local_only_from_meta() { + # fm_task_is_local_only reads mode= from state/<task>.meta. Only mode=local-only + # is local-only; any other mode, or an absent meta, is treated as not local-only + # (a local-only task has no PR, so it never gets the Open PR button). + local dir state + dir=$(make_case task-mode); state="$dir/state" + printf 'window=s:fm-a\nmode=local-only\nyolo=off\n' > "$state/a.meta" + printf 'window=s:fm-b\nmode=no-mistakes\n' > "$state/b.meta" + printf 'window=s:fm-c\nmode=direct-PR\n' > "$state/c.meta" + fm_task_is_local_only "$state" a || fail "mode=local-only not detected as local-only" + fm_task_is_local_only "$state" b && fail "mode=no-mistakes wrongly detected as local-only" + fm_task_is_local_only "$state" c && fail "mode=direct-PR wrongly detected as local-only" + fm_task_is_local_only "$state" missing && fail "absent meta wrongly detected as local-only" + pass "fm_task_is_local_only reads mode= and treats only local-only (not unknown) as local-only" +} + test_stale_is_terminal_classifier() { local dir state dir=$(make_case classify-stale); state="$dir/state" @@ -578,7 +614,211 @@ test_afk_present_reverts_watcher_to_one_shot() { pass "with .afk present the watcher reverts to one-shot so the daemon owns triage (no double-triage)" } +# --- normal-mode outbound notifier hook (fires the toast off the signal path) - + +# A fake fm-notify.sh that NUL-logs every arg it receives, with an optional exit +# code so a test can simulate a FAILING notifier (the error-isolation contract). +make_notify_fake() { # <path> <log> [rc] + local path=$1 log=$2 rc=${3:-0} + cat > "$path" <<SH +#!/usr/bin/env bash +printf '%s\0' "\$@" >> "$log" +exit $rc +SH + chmod +x "$path" +} + +test_signal_notifier_fires_for_captain_relevant() { + local dir state fakebin out notifylog fake pid args + dir=$(make_case notify-fires); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + printf 'working: setup\nneeds-decision: pick A or B\n' > "$state/task.status" + # FM_WATCH_NOTIFY_BG=0 runs the toast synchronously so it is logged before exit. + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for a captain-relevant signal" + grep -F "signal: $state/task.status" "$out" >/dev/null || fail "watcher did not surface the signal" + [ -s "$notifylog" ] || fail "captain-relevant signal did not fire the notifier" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "Firstmate" "notifier title missing" + assert_contains "$args" "--focus" "notifier was not asked to focus" + assert_contains "$args" "need your decision" "summary did not mirror the daemon's decision class" + assert_not_contains "$args" "pick A or B" "raw status text leaked into the lock-screen summary" + assert_not_contains "$args" "task.status" "internal status filename leaked into the summary" + pass "normal-mode signal with a captain-relevant verb fires the notifier (id-free, decision class)" +} + +test_signal_notifier_review_class_and_dedup() { + local dir state fakebin out notifylog fake pid args n status_file + dir=$(make_case notify-dedup); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + status_file="$state/task.status" + printf 'done: PR https://example.test/pr/7 checks green\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for the done: signal" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "ready for review" "summary did not mirror the daemon's review class" + # Second wake on the SAME status content (bumped mtime -> fresh signal) must NOT + # toast again: the .notify-seen-<task> content marker dedupes it. + touch "$status_file" + : > "$out" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit on the second (re-touched) signal" + grep -F "signal: $status_file" "$out" >/dev/null || fail "watcher did not still wake on the second signal" + n=$(tr '\0' '\n' < "$notifylog" | grep -c '^Firstmate$') + [ "$n" -eq 1 ] || fail "the same status was toasted $n times; expected exactly 1 (dedup failed)" + [ -s "$state/.notify-seen-task" ] || fail "the per-task notify dedup marker was not recorded" + pass "review-class summary fires once and the same status is never toasted twice (dedup)" +} + +test_signal_notifier_silent_for_working_note() { + local dir state fakebin out notifylog fake pid status_file + dir=$(make_case notify-working-silent); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + status_file="$state/task.status" + printf 'working: compiling\n' > "$status_file" + # A no-verb working: note whose crew is NOT provably working is SURFACED, but it + # carries no captain-relevant verb, so it must never toast. + export FM_FAKE_CREW_STATE='state: working · source: status-log · working: compiling' + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not surface the working: note" + grep -F "signal: $status_file" "$out" >/dev/null || fail "watcher did not surface the working: signal" + [ ! -s "$notifylog" ] || fail "a working: note (no captain-relevant verb) fired the notifier" + unset FM_FAKE_CREW_STATE + pass "a surfaced working: note never toasts (only captain-relevant verbs do)" +} + +test_signal_notifier_gated_by_fm_notify_off() { + local dir state fakebin out notifylog fake pid status_file + dir=$(make_case notify-off-gate); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + status_file="$state/task.status" + printf 'needs-decision: pick A or B\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=off FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for the captain-relevant signal under FM_NOTIFY=off" + grep -F "signal: $status_file" "$out" >/dev/null || fail "FM_NOTIFY=off changed the wake/exit behavior" + [ ! -s "$notifylog" ] || fail "FM_NOTIFY=off did not silence the watcher notifier" + pass "FM_NOTIFY=off silences the watcher notifier but never changes the wake" +} + +test_signal_notifier_error_does_not_change_wake() { + # HARD requirement: a FAILING notifier must NEVER change the watcher's wake/exit. + local dir state fakebin out drain_out notifylog fake pid + dir=$(make_case notify-error-isolated); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out" + notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" 1 # exit 1 = error + printf 'needs-decision: pick A or B\n' > "$state/task.status" + # Synchronous (BG=0) so the failing exec runs inline on the wake path; if the + # error were not isolated, it would change the watcher's behavior here. + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "a failing notifier blocked or hung the watcher" + grep -F "signal: $state/task.status" "$out" >/dev/null || fail "a failing notifier suppressed the signal wake" + [ -s "$notifylog" ] || fail "the notifier was expected to have been invoked (and failed)" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain failed after a failing notifier" + grep "$(printf '\tsignal\t')" "$drain_out" | grep -F "$state/task.status" >/dev/null \ + || fail "the wake was not queued when the notifier errored" + pass "a failing notifier never changes the watcher wake/exit (still surfaces + queues)" +} + +test_signal_notifier_skipped_while_afk() { + # In away mode the daemon owns notifications (notify_escalation), so the watcher + # hook must NOT also toast - it would double-ping the captain. + local dir state fakebin out notifylog fake pid status_file + dir=$(make_case notify-afk-skip); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + status_file="$state/task.status" + printf 'needs-decision: pick A or B\n' > "$status_file" + date '+%s' > "$state/.afk" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "afk-mode watcher did not exit one-shot for the signal" + grep -F "signal: $status_file" "$out" >/dev/null || fail "afk-mode watcher did not surface the signal" + [ ! -s "$notifylog" ] || fail "the watcher toasted while afk (the daemon owns notifications then)" + pass "while afk the watcher does not toast (the daemon's notify_escalation owns it)" +} + +test_signal_notifier_open_pr_for_done_with_url() { + # A done: status carrying a PR URL on a non-local-only task passes --open <url> + # so the toast gets an "Open PR" button. The PR URL must NOT leak into the + # lock-screen summary text. + local dir state fakebin out notifylog fake pid args url + dir=$(make_case notify-open-pr); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + url="https://github.com/karotkriss/firstmate/pull/4" + printf 'window=s:fm-task\nmode=no-mistakes\n' > "$state/task.meta" + printf 'done: PR %s checks green\n' "$url" > "$state/task.status" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for the done: + PR signal" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "--open" "a done: status with a PR URL did not pass --open" + assert_contains "$args" "$url" "the PR URL was not passed to --open" + assert_contains "$args" "ready for review" "summary did not reflect the review class" + # The summary line itself is still id-free; the URL rides only the --open flag. + pass "watcher passes --open <url> for a done: status carrying a PR URL" +} + +test_signal_notifier_no_open_pr_for_local_only() { + # A local-only task has no PR, so even a done: status must NOT get an Open PR + # button: --open is omitted when the task's mode is local-only. + local dir state fakebin out notifylog fake pid args + dir=$(make_case notify-open-local-only); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + printf 'window=s:fm-task\nmode=local-only\n' > "$state/task.meta" + # Even if a URL-shaped token appears, a local-only task is gated out. + printf 'done: ready in branch fm/task https://github.com/o/r/pull/9\n' > "$state/task.status" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for the local-only done: signal" + args=$(tr '\0' '\n' < "$notifylog") + [ -s "$notifylog" ] || fail "the local-only done: still should toast (just without --open)" + assert_not_contains "$args" "--open" "a local-only task must not get an Open PR button" + pass "watcher omits --open for a local-only task (no PR to open)" +} + +test_signal_notifier_no_open_pr_for_decision_class() { + # A mixed wake - one task needs-decision, another done: with a PR URL - is + # classified as a decision wake (needs-decision wins). The Open PR button is + # reserved for review/done-state toasts, so --open must be omitted: an urgent + # decision toast must not also carry a button pointing at a different item. + local dir state fakebin out notifylog fake pid args url + dir=$(make_case notify-decision-no-open); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; notifylog="$dir/notify.log"; : > "$notifylog" + fake="$dir/fake-notify.sh"; make_notify_fake "$fake" "$notifylog" + url="https://github.com/karotkriss/firstmate/pull/4" + # Two tasks wake together; the done: task is non-local-only so it would normally + # earn --open. The decision classification must still suppress the button. + printf 'window=s:fm-don\nmode=no-mistakes\n' > "$state/don.meta" + printf 'done: PR %s checks green\n' "$url" > "$state/don.status" + printf 'needs-decision: pick A or B\n' > "$state/dec.status" + watch_bg "$state" "$fakebin" "$out" FM_NOTIFY=on FM_NOTIFY_CMD="$fake" FM_WATCH_NOTIFY_BG=0 + pid=$! + wait_for_exit "$pid" 40 || fail "watcher did not exit for the mixed decision+done wake" + [ -s "$notifylog" ] || fail "a mixed decision+done wake should still toast" + args=$(tr '\0' '\n' < "$notifylog") + assert_contains "$args" "--focus" "decision-class toast should still request focus" + assert_contains "$args" "need your decision" "mixed wake should classify as a decision wake" + assert_not_contains "$args" "--open" "a decision-class wake must not carry --open" + assert_not_contains "$args" "$url" "the PR URL must not ride a decision-class toast" + pass "watcher suppresses --open when the wake is classified as a decision" +} + test_signal_reason_is_actionable_classifier +test_status_pr_url_extractor +test_task_is_local_only_from_meta test_stale_is_terminal_classifier test_scan_captain_relevant_statuses_classifier test_classifier_primitives @@ -598,3 +838,12 @@ test_heartbeat_no_change_absorbed test_heartbeat_backstop_surfaces_unsurfaced_status test_beacon_stays_fresh_while_absorbing test_afk_present_reverts_watcher_to_one_shot +test_signal_notifier_fires_for_captain_relevant +test_signal_notifier_review_class_and_dedup +test_signal_notifier_silent_for_working_note +test_signal_notifier_gated_by_fm_notify_off +test_signal_notifier_error_does_not_change_wake +test_signal_notifier_skipped_while_afk +test_signal_notifier_open_pr_for_done_with_url +test_signal_notifier_no_open_pr_for_local_only +test_signal_notifier_no_open_pr_for_decision_class