Skip to content

feat(notify): native out-of-band notifier for captain escalations#148

Open
karotkriss wants to merge 12 commits into
kunchenguid:mainfrom
karotkriss:fm/notifier-v2
Open

feat(notify): native out-of-band notifier for captain escalations#148
karotkriss wants to merge 12 commits into
kunchenguid:mainfrom
karotkriss:fm/notifier-v2

Conversation

@karotkriss

@karotkriss karotkriss commented Jun 30, 2026

Copy link
Copy Markdown

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).

image

firstmate notifications are more detailed in practice

Behavior

  • Persistent, sounding, click-to-focus - never auto-focus. The toast pops, sounds, and stays up by default. Stealing the foreground was deliberately rejected as invasive; instead, 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 between the notifier and the click handler via bin/fm-focus-lib.sh so they cannot drift.
  • Open PR button, gated to review/done-state notifications. fm-notify.sh --open <url> adds a second action that opens the PR. It rides only when a captain-relevant done: status carries a PR/MR URL and the task is not local-only; a mixed wake that pairs a needs-decision/blocked/failed item with a done: 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_NOTIFY kill switch. FM_NOTIFY=off (and the aliases 0/false/no/disabled/disable) silences everything. The gate is enforced before any command runs, so a custom FM_NOTIFY_CMD override can never become a bypass.

Platform backends

Each backend mirrors the behavior as closely as it robustly allows:

  • WSL / Windows: a raw WinRT toast via 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 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; every registry write is checked so a partial install never reports "armed").
  • macOS: terminal-notifier when present (persistent alert + sound + click-to-focus), else osascript display notification with a sound.
  • Linux: notify-send --urgency=critical (persistent), a best-effort sound, and a notify-send --action click-to-focus where the desktop's notification daemon supports actions; degrades to persistent+sound otherwise.

Note: the macOS and Linux paths are unit-tested for dispatch and argument-building but not end-to-end verified, since firstmate develops on WSL. This is a known, documented limitation.

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-only base64 -w0) for BSD/macOS portability. The WSL launcher preserves FM_HOME and quotes the distro and checkout path so a custom home and paths with spaces still work.

Wiring

  • The away-mode daemon calls the notifier only on a confirmed escalation delivery, never on routine self-handled wakes.
  • The always-on watcher calls it 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.

Shared helpers fm_status_pr_url / fm_task_is_local_only live in bin/fm-classify-lib.sh and are used by both hooks.

Tests

tests/fm-notify.test.sh and tests/fm-focus.test.sh cover the platform-portable logic; tests/fm-watch-triage.test.sh covers the watcher hook (including the local-only gate); tests/fm-daemon.test.sh covers the daemon hook. The e2e suites export FM_NOTIFY=off so 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) with export 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 adding export FM_NOTIFY=off at 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 &#34;$t&#34;; done - all 23 suites pass (fm-notify, fm-focus, fm-watch-triage, fm-daemon, fm-wake-queue, etc.)
  • Verified WSL interop: powershell.exe -Command &#39;Write-Output interop-ok&#39; and . bin/fm-focus-lib.sh; fm_detect_platform -> wsl
  • Fired a real toast on Windows: FM_NOTIFY_BG=0 bin/fm-notify.sh &#34;Firstmate&#34; &#34;1 item(s) ready for review&#34; --focus --open &lt;pr-url&gt; -> 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 real bin/fm-watch.sh signal hook with a recording notifier across 6 scenarios, capturing the exact fm-notify.sh command per case (decision/review/local-only/mixed/working/FM_NOTIFY=off)
  • Demonstrated injection neutralization by feeding hostile title/message/URL through _build_windows_ps and confirming base64 carriage of title/message and XML-escaping of the URL
  • Rendered 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

    • Added native out-of-band OS notifications for watcher and escalation events, including optional click-to-focus and an “Open PR” action for eligible done states.
    • Added a cross-platform focus flow that brings the correct terminal/tmux pane to the foreground.
    • Documented new notification and focusing configuration options.
  • Bug Fixes

    • Notifications are deduplicated, AFK-suppressed, and fully gated by FM_NOTIFY (even against custom notifier commands).
    • Notifier failures are isolated and do not block escalation delivery.
  • Tests

    • Added/expanded notifier and focus test suites and updated e2e tests to disable real notifications.

…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.
@karotkriss

karotkriss commented Jun 30, 2026

Copy link
Copy Markdown
Author

@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
@karotkriss

karotkriss commented Jun 30, 2026

Copy link
Copy Markdown
Author

Pushed a small follow-up commit to make the behavior-tests suite robust on Linux CI runners.

The macOS osascript-fallback test asserts the no-terminal-notifier path: fm_notify_macos only falls back to osascript display notification when terminal-notifier is absent. On a runner that ships terminal-notifier (some Ubuntu images carry it as a Ruby gem binstub), fm_notify_macos takes the terminal-notifier branch and never calls osascript, so that single assertion can't hold. The commit gates just that test via fm_detect_platform plus a terminal-notifier-presence check, skipping cleanly with a passing line elsewhere so the harness count stays consistent. The Windows (PowerShell) and Linux (notify-send) dispatch tests stub their tools, so they were unaffected and are left as-is.

Verified all three CI jobs locally against the updated branch tip:

  • Lint - shellcheck bin/*.sh tests/*.sh clean.
  • Behavior tests - the full tests/*.test.sh loop, run both plainly and with a terminal-notifier on PATH to mirror the runner; 23/23 green.
  • Repo invariants - both symlinks intact, no personal fleet paths tracked.

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.
@karotkriss

karotkriss commented Jun 30, 2026

Copy link
Copy Markdown
Author

@kunchenguid Fixed a regression in the focus logic and enable the workflows on my folks so tests wouldnt slip by before hitting your repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: pluggable out-of-band notifier (push) for captain-relevant wakes

1 participant