feat(notify): native out-of-band notifier for captain escalations#148
feat(notify): native out-of-band notifier for captain escalations#148karotkriss wants to merge 12 commits into
Conversation
…nchenguid#106) Add bin/fm-notify.sh, the first backend of the pluggable out-of-band notifier from issue kunchenguid#106. It fires a native OS notification so the captain is pinged off the pane the moment captain-relevant work needs them, in BOTH supervision modes. The notification pops, sounds, and persists by default and is click-to-focus, never auto-focus (stealing the foreground was rejected as invasive): clicking "Go to firstmate" raises the host terminal and selects the firstmate tmux window+pane, resolved dynamically at click time with nothing hardcoded. The resolution is shared with the click handler via bin/fm-focus-lib.sh so the notifier and bin/fm-focus.sh cannot drift. Platform backends, each mirroring the behavior as closely as it robustly allows: - WSL / Windows: a raw WinRT toast via powershell.exe (no modules), scenario="reminder" so it persists, an explicit <audio> so it always sounds, and a "Go to firstmate" action wired to a registered "firstmate:" URL protocol -> hidden VBS launcher -> bin/fm-focus.sh. `fm-notify.sh install` arms the protocol + launcher and records the pane id (idempotent; a clean no-op off WSL, and every registry write is checked so a partial install never reports "armed"). Title and message are carried to PowerShell as base64 and set via DOM text nodes, so arbitrary escalation text can never inject into the generated script or the toast markup. - macOS: terminal-notifier when present (persistent alert + sound + -execute click-to-focus), else osascript display notification with a sound. - Linux: notify-send --urgency=critical (persistent), a best-effort sound, and notify-send --action click-to-focus where the desktop's daemon supports actions; degrades to persistent+sound otherwise. The macOS/Linux paths are unit-tested for dispatch and argument-building but not end-to-end verified, since firstmate develops on WSL. Fire it from BOTH supervisors, sharing one definition of the FM_NOTIFY toggle (FM_NOTIFY=off, also 0/false/no/disabled, silences everything): - The away-mode sub-supervisor (bin/fm-supervise-daemon.sh) calls it only on a confirmed escalation delivery, never on routine self-handled wakes. - The always-on watcher (bin/fm-watch.sh) calls it from its signal path the moment a wake carries a captain-relevant verb in NORMAL operation too, never on working:/turn-end/heartbeat wakes; it dedupes per task by content, stands down while afk (the daemon owns notifications then), and is fully error-isolated and backgrounded so a missing, failing, or slow notifier can never change the wake. Both emit the same id-free count-and-class summary (lock-screen safe), and the FM_NOTIFY=off gate is enforced before any command runs so a custom FM_NOTIFY_CMD override cannot become a bypass. Cover the platform-portable logic with tests/fm-notify.test.sh and tests/fm-focus.test.sh, the watcher hook in tests/fm-watch-triage.test.sh, and the daemon hook in tests/fm-daemon.test.sh; the e2e suites export FM_NOTIFY=off so the hooks never fire a real toast in CI. Document the notifier, the FM_NOTIFY toggle, and the focus limitations across AGENTS.md, docs/, CONTRIBUTING.md, and the afk skill. No workflow files are modified.
When a crewmate finishes with a PR, give the captain a second toast
button that opens it. bin/fm-notify.sh gains an optional --open <url>
flag that adds an "Open PR" action alongside "Go to firstmate":
- WSL / Windows: a real second toast button
(<action content="Open PR" activationType="protocol"
arguments="<url>"/>) that opens the default browser; the URL is
XML-attribute-escaped so it can never break the toast markup.
- macOS: best-effort terminal-notifier -open <url> (terminal-notifier
has a single click action, so -open replaces the focus -execute when
a URL is present).
- Linux: best-effort second notify-send --action that runs
xdg-open <url> where the daemon supports actions.
Without --open the notification is unchanged (no extra button).
Wire it into both notifier hooks. When a captain-relevant done: status
carries a PR/MR URL (a GitHub /pull/<n> or GitLab /merge_requests/<n>)
and the task's project is not local-only (read from mode= in the task's
state/<id>.meta), the watcher's notify_signal_statuses and the daemon's
notify_escalation pass --open <url>; local-only tasks have no PR and get
no button. The URL rides only the flag, so the id-free summary text is
unchanged. Add shared fm_status_pr_url / fm_task_is_local_only helpers to
the classifier lib (bin/fm-classify-lib.sh), used by both hooks.
Tests: the --open dispatch on all three platforms plus URL XML-escaping
(tests/fm-notify.test.sh); the PR-URL and local-only helpers and the
watcher's --open wiring incl. the local-only gate
(tests/fm-watch-triage.test.sh); and the daemon's done-state --open pass
(tests/fm-daemon.test.sh). Docs updated across AGENTS.md, docs/, and
CONTRIBUTING.md.
…lity Preserve FM_HOME and quote the distro and path in the WSL click-to-focus launcher, so a custom home clicks into the pane it recorded and a distro name or checkout path with spaces still reaches bash as one argument (the macOS/Linux backends already pass FM_HOME through). Reject incomplete fm-notify invocations (a bare --open with no URL, or a missing title or message) with the usage error instead of falling through and reporting success. Use `base64 | tr -d '\n'` rather than GNU-only `base64 -w0` so the notifier and its test suite run on BSD/macOS too, and cover the singular `disable` FM_NOTIFY alias.
When a wake or escalation digest mixes a needs-decision/blocked/failed item with a done: PR line, it was classified as a decision notification but still attached an Open PR button pointing at the unrelated done item. Clear the URL on the decision branch in both the watcher and the daemon so --open rides only review/done-state notifications. Tighten the local-only test to assert the notifier still fires, and add regression tests for the gated mixed-wake case in both paths.
|
@kunchenguid I saw an opportunity to improve the quality of life for firstmate. removed the need to constantly check firstmate for progress/escalations with notifications when you (the captain) is needed. Fully tested on Windows/WSL and Supports MacOS and Linux |
feat(notify): native out-of-band notifier with click-to-focus and Open PR action
|
Pushed a small follow-up commit to make the behavior-tests suite robust on Linux CI runners. The macOS Verified all three CI jobs locally against the updated branch tip:
I will run no mistakes again |
…tifier-free macOS host The osascript fallback path is only reached on macOS when terminal-notifier is absent, so the test asserted a path that is not taken on a host where terminal-notifier is present (some Linux runners ship it as a Ruby gem binstub). Skip cleanly via fm_detect_platform plus a terminal-notifier presence check, emitting one passing line so the harness count stays consistent.
The Windows toast's "Go to firstmate" click ran a VBS launcher whose wsl.exe invocation quoted the distro (-d "Ubuntu") and passed the home through an `env FM_HOME=...` wrapper. WScript.Shell.Run preserves neither: the quotes reach wsl.exe literally so the distro never resolves, and the env wrapper is mangled, so the click landed on a dead path and the terminal was never raised. Pass the distro unquoted and hand the home to fm-focus.sh as its positional argument, which survives the VBS -> wsl.exe chain. fm-focus.sh now reads $1 as the home, with the FM_HOME env and the self-derived repo root remaining the fallbacks for the macOS/Linux backends. Update the launcher comment and the WSL install test to the new format, and add focus coverage proving the positional home wins over an ambient FM_HOME.
… positional-arg launcher
2b47961 to
b8cef8f
Compare
|
@kunchenguid Fixed a regression in the focus logic and enable the workflows on my folks so tests wouldnt slip by before hitting your repo. |
Summary
A native out-of-band notifier for captain-relevant escalations (the first backend of the pluggable notifier requested in #106). It fires a native OS notification so the captain is pinged off the tmux pane the moment captain-relevant work needs them, in both supervision modes - the always-on watcher (
bin/fm-watch.sh) and the away-mode sub-supervisor daemon (bin/fm-supervise-daemon.sh).Behavior
bin/fm-focus-lib.shso they cannot drift.fm-notify.sh --open <url>adds a second action that opens the PR. It rides only when a captain-relevantdone:status carries a PR/MR URL and the task is notlocal-only; a mixed wake that pairs aneeds-decision/blocked/faileditem with adone:line stays a decision notification and gets no Open-PR button. The URL rides only the flag, so the id-free summary text is unchanged.FM_NOTIFYkill switch.FM_NOTIFY=off(and the aliases0/false/no/disabled/disable) silences everything. The gate is enforced before any command runs, so a customFM_NOTIFY_CMDoverride can never become a bypass.Platform backends
Each backend mirrors the behavior as closely as it robustly allows:
powershell.exe(no PowerShell modules),scenario="reminder"so it persists, an explicit<audio>so it always sounds, and a "Go to firstmate" action wired to a registeredfirstmate:URL protocol -> hidden VBS launcher ->bin/fm-focus.sh.fm-notify.sh installarms the protocol + launcher and records the pane id (idempotent; a clean no-op off WSL; every registry write is checked so a partial install never reports "armed").terminal-notifierwhen present (persistent alert + sound + click-to-focus), elseosascript display notificationwith a sound.notify-send --urgency=critical(persistent), a best-effort sound, and anotify-send --actionclick-to-focus where the desktop's notification daemon supports actions; degrades to persistent+sound otherwise.Security
Arbitrary escalation title/message text is carried to PowerShell as base64 and set via DOM text nodes, and the Open-PR URL is XML-attribute-escaped, so escalation text can never inject into the generated script or the toast markup. base64 uses
base64 | tr -d '\n'(not GNU-onlybase64 -w0) for BSD/macOS portability. The WSL launcher preservesFM_HOMEand quotes the distro and checkout path so a custom home and paths with spaces still work.Wiring
working:/turn-end/heartbeat wakes; it dedupes per task by content, stands down while afk (the daemon owns notifications then), and is fully error-isolated and backgrounded so a missing, failing, or slow notifier can never change the wake.Shared helpers
fm_status_pr_url/fm_task_is_local_onlylive inbin/fm-classify-lib.shand are used by both hooks.Tests
tests/fm-notify.test.shandtests/fm-focus.test.shcover the platform-portable logic;tests/fm-watch-triage.test.shcovers the watcher hook (including the local-only gate);tests/fm-daemon.test.shcovers the daemon hook. The e2e suites exportFM_NOTIFY=offso the hooks never fire a real toast in CI. No GitHub workflow files are modified.Closes #106.
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
tests/fm-wake-queue.test.sh:63- tests/fm-wake-queue.test.sh writes captain-relevant statuses (blocked: line 62, done: lines 69/92) into real .status files and runs the real bin/fm-watch.sh with no FM_NOTIFY=off and no FM_NOTIFY_CMD mock. The new signal-path notifier hook (fm-watch.sh:452) now execs the real bin/fm-notify.sh, firing real OS notifications (Windows toasts) when this suite runs on a developer/WSL machine. The author guarded the three sibling suites that drive this path (fm-afk-inject-e2e, fm-wake-daemon-lifecycle-e2e, fm-daemon) withexport FM_NOTIFY=off, but this one was missed. CI stays green and the test still passes (headless CI has no notify backend), so impact is limited to local-dev toast spam; fix by addingexport FM_NOTIFY=offat file scope to match the other suites.🔧 Fix: guard fm-wake-queue test against real OS notifications
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"for t in tests/*.test.sh; do bash "$t"; done- all 23 suites pass (fm-notify, fm-focus, fm-watch-triage, fm-daemon, fm-wake-queue, etc.)Verified WSL interop:powershell.exe -Command 'Write-Output interop-ok'and. bin/fm-focus-lib.sh; fm_detect_platform->wslFired a real toast on Windows:FM_NOTIFY_BG=0 bin/fm-notify.sh "Firstmate" "1 item(s) ready for review" --focus --open <pr-url>-> rc=0 (toast dispatched; OS routed it silently to the Action Center because this machine's notification banners are in Focus/DND)Captured the actual generated toast markup for both variants via_build_windows_ps(done+OpenPR: scenario=reminder, audio, Go to firstmate + Open PR + Dismiss; decision: Go to firstmate + Dismiss only)Drove the realbin/fm-watch.shsignal hook with a recording notifier across 6 scenarios, capturing the exactfm-notify.shcommand per case (decision/review/local-only/mixed/working/FM_NOTIFY=off)Demonstrated injection neutralization by feeding hostile title/message/URL through_build_windows_psand confirming base64 carriage of title/message and XML-escaping of the URLRendered a faithful visual of both toast variants from the generated markup via Edge headless✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
Summary by CodeRabbit
New Features
Bug Fixes
FM_NOTIFY(even against custom notifier commands).Tests