Skip to content

fix(notify): native desktop notifications on macOS/Windows + warn when inert (#231)#282

Merged
pbean merged 3 commits into
mainfrom
fix/notify-desktop-cross-platform-231
Jul 24, 2026
Merged

fix(notify): native desktop notifications on macOS/Windows + warn when inert (#231)#282
pbean merged 3 commits into
mainfrom
fix/notify-desktop-cross-platform-231

Conversation

@pbean

@pbean pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #231

Problem

notify.desktop defaults to true, but the only desktop channel was notify-send (Linux-only). On macOS/Windows the shutil.which("notify-send") guard made the call a silent no-op — the setting was enabled, valid, and did nothing, with no indication anywhere. Since gates.notify is the single choke every "a human is needed" path funnels through (escalations, deferrals, worktree-open failures, run-finished), an unattended macOS/Windows run that needed a human had no channel but the untailed ATTENTION file in .bmad-loop/.

Fix

A small notifier-resolution seam in gates.py, consumed by notify, validate, and the engine:

  • Native per-platform dispatch in gates.notify: osascript (macOS), a best-effort WinRT ToastNotificationManager PowerShell toast (Windows, dependency-free), notify-send (Linux). Resolution is gated on sys.platform first (not which alone) so PowerShell Core on Linux can't divert Linux off notify-send. Windows stays strictly best-effort inside the existing try/except — a failure only ever no-ops.
  • Untrusted title/message are passed via environment variables (system attribute / $env:), never interpolated into the osascript/PowerShell command string, so quotes/newlines/metacharacters in story keys or error tails can't break out. notify-send keeps taking them as argv (already injection-safe). A test proves the raw text lands in env, not argv.
  • Honest warning when inert, in two places so unattended launches that skip validate still surface it:
    • validate emits a notify.desktop-unavailable warning finding when notify.desktop is set but no notifier resolves on the platform.
    • Run start prints a one-time stderr warning: line and journals a notify-desktop-unavailable event (gated by _owns_signals so nested auto-sweeps don't repeat it).

No new policy field — reuses the existing notify.desktop / notify.file.

Tests

  • New tests/test_gates.py: file sink; desktop_notifier_kind per platform + None; osascript/PowerShell/notify-send argv+env dispatch; the untrusted-text-via-env-not-argv assertion; no-op when no notifier; error swallow.
  • tests/test_cli.py: validate warns when no notifier / silent when one is available.
  • tests/test_engine.py: _warn_desktop_notifier_inert journals + prints when inert / no-ops when a notifier is present.

Full suite green (2928 passed, 1 skipped); trunk check --no-fix clean. The Windows toast path is unverifiable on this repo's Linux/macOS CI and is best-effort by design.

Summary by CodeRabbit

  • Bug Fixes
    • Desktop notifications now use platform-native delivery on macOS, Windows, and Linux (best-effort).
    • Untrusted notification title/message text is handled safely to avoid unsafe command construction.
    • Added validation and one-time startup warnings when desktop notifications are enabled but unavailable, with guidance pointing to the run-directory alert behavior.
  • Documentation
    • Expanded [notify] desktop = true policy documentation to reflect platform-specific mechanisms.
  • Tests
    • Added/updated tests for notifier dispatch, selection behavior, and the new unavailable-notifier warning.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 29b3f51b-a499-4d4a-ae73-835b9ec07be2

📥 Commits

Reviewing files that changed from the base of the PR and between c1cf106 and b9dac20.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/gates.py
  • tests/test_engine.py
  • tests/test_gates.py
  • tests/test_tui_app.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • CHANGELOG.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/gates.py
  • tests/test_gates.py

Walkthrough

notify.desktop now dispatches best-effort notifications through notify-send, osascript, or PowerShell toast. Validation and run startup report unavailable notifiers, while tests and documentation cover the new behavior.

Changes

Desktop notification delivery

Layer / File(s) Summary
Platform notifier dispatch
src/bmad_loop/gates.py, tests/test_gates.py
Desktop notifications select an available platform backend, pass macOS and Windows message text through environment variables, preserve the ATTENTION file sink, and swallow subprocess failures.
Availability validation and startup warnings
src/bmad_loop/checks.py, src/bmad_loop/cli.py, src/bmad_loop/engine.py, tests/test_engine.py
Validation reports notify.desktop-unavailable; top-level engine runs journal the condition and print a startup warning while nested runs suppress duplicates.
Behavior coverage and policy documentation
tests/test_cli.py, README.md, src/bmad_loop/policy.py, CHANGELOG.md, tests/test_tui_app.py
Tests cover platform selection, safe argument passing, unavailable tools, and warning behavior; documentation describes supported mechanisms and best-effort delivery.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: dracic

Poem

I’m a rabbit with alerts in my burrow tonight,
macOS, Windows, Linux—all blinking bright.
Safe little messages hop through the air,
And warnings leave journal tracks with care.
ATTENTION still waits where the run paths go—
Thump, thump, notify: off we go!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The TUI test helper change in tests/test_tui_app.py appears unrelated to the notify.desktop work and is outside the linked issue scope. Split the TUI helper change into a separate PR unless it is required for this notification fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 41.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: native desktop notifications for macOS/Windows plus inert-platform warnings.
Linked Issues check ✅ Passed The PR implements native macOS/Windows notifications and adds validation/startup warnings when desktop notifications are unavailable.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/notify-desktop-cross-platform-231

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/test_gates.py (1)

109-124: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert that the Windows title is excluded from argv too.

This test only protects the message. A regression that interpolates the notification title into the PowerShell command would still pass. Use a title containing shell/PowerShell-sensitive text and assert neither title nor message appears in argv.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gates.py` around lines 109 - 124, Update
test_notify_windows_runs_powershell to use a title containing
PowerShell-sensitive characters, then assert that neither the title nor the
message appears in any argv element while preserving the existing
environment-variable assertions.
tests/test_engine.py (1)

119-130: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise the “once per run” guarantee.

This test invokes _warn_desktop_notifier_inert() only once, so it cannot detect duplicate journal entries or stderr warnings if startup reaches the helper twice. Invoke it twice and assert exactly one event and one warning line.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_engine.py` around lines 119 - 130, The test for
_warn_desktop_notifier_inert should verify the once-per-run behavior by invoking
the helper twice, then asserting exactly one "notify-desktop-unavailable"
journal entry and exactly one matching stderr warning line. Update
test_warn_desktop_notifier_inert_journals_and_prints while preserving the
existing notifier setup and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 70-78: Update the changelog entry for notify.desktop to limit the
environment-variable claim to macOS and Windows, and explicitly state that Linux
passes the title and message through notify-send argv without shell
interpolation. Preserve the surrounding platform-dispatch and warning details.

In `@src/bmad_loop/cli.py`:
- Around line 265-274: The desktop-unavailable warning in src/bmad_loop/cli.py
lines 265-274 must conditionally mention the ATTENTION file only when
pol.notify.file is enabled; otherwise state that no alert channel is configured
and suggest enabling file notifications. Apply the same conditional guidance to
the stderr message in src/bmad_loop/engine.py lines 315-320 using
self.policy.notify.file.

In `@src/bmad_loop/engine.py`:
- Around line 329-330: Update the warning condition in the engine run flow
around _warn_desktop_notifier_inert() to suppress warnings only when _is_nested
is true. Ensure top-level runs, including those unable to install signal
handlers because they are off the main thread, still invoke the desktop notifier
warning.

In `@src/bmad_loop/gates.py`:
- Around line 24-38: Update the Windows PowerShell notifier around _WIN_TOAST_PS
and desktop_notifier_kind() so the AUMID used by
CreateToastNotifier('bmad-loop') is registered with a matching Start-menu
shortcut before exposing the "powershell" notifier. Perform this registration
during installation or validate it at notification time, and only select the
PowerShell path when the identity is available; otherwise use an
already-registered delivery path or disable the notifier.

---

Nitpick comments:
In `@tests/test_engine.py`:
- Around line 119-130: The test for _warn_desktop_notifier_inert should verify
the once-per-run behavior by invoking the helper twice, then asserting exactly
one "notify-desktop-unavailable" journal entry and exactly one matching stderr
warning line. Update test_warn_desktop_notifier_inert_journals_and_prints while
preserving the existing notifier setup and assertions.

In `@tests/test_gates.py`:
- Around line 109-124: Update test_notify_windows_runs_powershell to use a title
containing PowerShell-sensitive characters, then assert that neither the title
nor the message appears in any argv element while preserving the existing
environment-variable assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 090ffc30-8446-4c2b-bb5a-1f914ec80fc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3f59ee4 and c1cf106.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/gates.py
  • src/bmad_loop/policy.py
  • tests/test_cli.py
  • tests/test_engine.py
  • tests/test_gates.py

Comment thread CHANGELOG.md
Comment thread src/bmad_loop/cli.py
Comment thread src/bmad_loop/engine.py Outdated
Comment thread src/bmad_loop/gates.py
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @coderabbitai — validated each finding against the code (and current WinRT docs for the Windows one). Addressed in 00168bd:

  • Windows toast AUMID (gates.py) — ✅ Valid. CreateToastNotifier('bmad-loop') used an unregistered AUMID, which Windows drops without a Start-Menu shortcut. Rather than add install-time shortcut machinery (out of scope for this best-effort path), the toast now reuses Windows PowerShell's own default-registered AUMID ({1AC14E77-…}\WindowsPowerShell\v1.0\powershell.exe), so it actually displays (attributed to "Windows PowerShell"), no new dependency. Those _WIN_TOAST_PS segments are now raw strings so the \v1.0 backslash stays literal instead of becoming a vertical tab.
  • _owns_signals gate (engine.py) — ✅ Valid, good catch. Switched the run-start warning gate to if not self._is_nested:. _owns_signals is also False for a top-level run that couldn't install handlers off the main thread (the code's own comment notes this), which was wrongly silencing the warning; _is_nested still suppresses it for nested auto-sweeps.
  • ATTENTION-file guidance (cli.py + engine.py) — ✅ Valid. The message now branches on notify.file: only names the ATTENTION file as the remaining channel when file logging is on; when it's off, it says no alert channel is configured and to enable notify.file.
  • CHANGELOG wording — ✅ Valid. Reworded so the env-var claim is scoped to osascript/PowerShell; Linux passes text to notify-send as argv.
  • test_gates Windows title in argv — ✅ Valid. The Windows test now uses a PowerShell/shell-sensitive title and message and asserts neither reaches argv (plus that a registered AUMID is used).
  • test_engine "invoke twice, assert one" — ⏭️ Skipped. _warn_desktop_notifier_inert() is intentionally not idempotent; the once-per-run guarantee is the single guarded call site in run(), not the helper. Invoking it twice would legitimately warn twice, so that test would fail correct code. The existing single-call test already asserts exactly one event + one warning.

Docstring-coverage pre-merge warning is a CodeRabbit-wide metric (it counts test functions); this repo gates on trunk check (clean) and the new public functions carry docstrings. Full suite green (2928 passed, 1 skipped).

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline 00168bd
git diff 00168bd^ 00168bd -- \
  src/bmad_loop/gates.py \
  src/bmad_loop/engine.py \
  src/bmad_loop/cli.py \
  CHANGELOG.md \
  tests/test_gates.py \
  tests/test_engine.py

printf '\n--- gates outline ---\n'
ast-grep outline src/bmad_loop/gates.py --items all --match 'desktop_notifier_kind|notify|_WIN_TOAST_PS' --view expanded

printf '\n--- engine outline ---\n'
ast-grep outline src/bmad_loop/engine.py --items all --match 'run|_warn_desktop_notifier_inert' --view expanded

printf '\n--- relevant call sites and messages ---\n'
rg -n -C 5 '(_warn_desktop_notifier_inert|_is_nested|_owns_signals|CreateToastNotifier|BMAD_LOOP_NOTIFY|ATTENTION file|no alert channel|notify\.file)' \
  src/bmad_loop/gates.py src/bmad_loop/engine.py src/bmad_loop/cli.py tests/test_gates.py tests/test_engine.py CHANGELOG.md

Length of output: 31267


@pbean Verified 00168bd; the reported fixes are present and correctly cover the concerns:

  • The Windows toast now uses PowerShell’s registered AUMID, with the \v1.0 path preserved through raw-string segments.
  • Windows notification text remains environment-supplied and the strengthened test confirms neither sensitive title nor message reaches argv.
  • The startup warning is now correctly gated by not self._is_nested, so top-level off-main-thread runs are no longer skipped.
  • CLI and runtime guidance accurately reflects whether notify.file provides an ATTENTION fallback.
  • The changelog now distinguishes macOS/Windows environment passing from Linux notify-send argv passing.

Agreed on the helper test: _warn_desktop_notifier_inert() is intentionally non-idempotent; the once-per-owning-run behavior is enforced at its single guarded Engine.run() call site. That requested test does not apply.

🐇 ✅

@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Comprehensive review findings

Recommendation: request changes before merge. I found four implementation defects and three verification gaps in the final PR diff (3f59ee4...00168bd).

Findings

  1. High — embedded NUL text can crash the run (src/bmad_loop/gates.py:95). subprocess.run raises ValueError for NULs in argv/environment values, but the best-effort boundary catches only SubprocessError and OSError. Plugin veto reasons are unrestricted, so this is reachable. Reproduced locally as ValueError: embedded null byte.

  2. High — a pre_run pause bypasses the unavailable-notifier warning (src/bmad_loop/engine.py:335). _emit_run_boundary("pre_run") executes before _warn_desktop_notifier_inert(). A supported plugin pause veto raises immediately, leaving the run paused without the promised stderr warning or journal event.

  3. Medium — Linux notification text is vulnerable to option injection (src/bmad_loop/gates.py:80). The notify-send argv has no -- terminator. GLib parses options even after the title, so an untrusted message such as --help produces help output and no notification. Reproduced locally.

  4. Low — nested auto-sweeps can duplicate warnings off the main thread (src/bmad_loop/engine.py:497). _is_nested is derived solely from signal ownership. Off-main-thread engines cannot own signals, so both a parent and its synchronous child are classified non-nested and emit the warning/event.

  5. Medium — native macOS/Windows delivery is never exercised (tests/test_gates.py:19). Both native paths mock subprocess.run; Windows CI never executes the WinRT command, and there is no macOS job. The PR's central platform behavior therefore remains mock-only.

  6. Low — the PowerShell-Core-only dispatch path lacks coverage (tests/test_gates.py:67). Detection is tested when only pwsh exists, but the dispatch test supplies only powershell.exe. A regression between detection and executable selection would pass.

  7. Low — the warning policy matrix is incomplete. Tests do not verify that desktop=false suppresses warnings or that file=false emits no-channel guidance. Engine tests call the helper directly rather than exercising the new Engine.run() wiring.

Two additional candidates were dismissed: discarded nonzero notifier exits are intentional best-effort behavior, and the alleged PowerShell AUMID mismatch was not sufficiently supported as a defect.

Remediation plan

  1. Harden notification dispatch

    • Catch ValueError at the best-effort subprocess boundary.
    • Add -- before Linux title/message arguments.
    • Add regression tests for embedded NULs and option-shaped payloads.
  2. Correct the warning lifecycle

    • Emit the inert-notifier warning before a pausable pre_run boundary.
    • Track nested runs explicitly, preferably with context/thread-local run depth rather than signal ownership.
    • Exercise top-level, off-main-thread, nested, and pre-run-veto paths through Engine.run().
  3. Close platform verification gaps

    • Add a pwsh-only dispatch test.
    • Parameterize the desktop/file warning matrix.
    • Add platform-conditioned command-acceptance smoke tests and a documented visual smoke test where headless CI cannot observe notifications.
  4. Validate

    • Re-run focused notification/warning tests, full pytest, and trunk check --no-fix.

Current validation is otherwise green: all reported PR checks pass; locally, 2928 passed, 1 skipped, and trunk check --no-fix reports no issues.

…review)

Second-model review of PR #282 raised 7 findings; all validated true and
addressed on this branch.

- gates.notify: catch ValueError at the best-effort subprocess boundary. An
  embedded NUL in the untrusted title/message reaches argv (notify-send) or an
  env value (osascript/PowerShell) and makes subprocess.run raise ValueError,
  which is not a SubprocessError subclass and was escaping the best-effort catch.
- gates._notifier_argv: add a `--` terminator before the notify-send
  title/message so an option-shaped payload (e.g. `--help`) stays positional
  SUMMARY/BODY text instead of being parsed as a GLib option.
- engine.run: emit the inert-notifier warning before the pre_run boundary, whose
  plugin `pause` veto raises RunPaused and would otherwise skip the warning.
- engine: derive `_is_nested` from a module-level contextvars run-depth counter
  instead of signal ownership. An off-main-thread top-level run owns no signals
  yet is still depth-0 and must warn; a nested auto-sweep (run synchronously
  in-thread) is depth>0. Signal ownership now only governs handler install and
  session teardown.
- Document that native macOS/Windows delivery is unit-tested at the
  command-construction level (headless CI cannot observe a toast); manual smoke
  test noted in CHANGELOG + gates.notify docstring.

Tests: embedded-NUL/ValueError swallow, notify-send `--` terminator, pwsh-only
dispatch, the desktop/file warning matrix, and off-main-thread + nested run()
wiring. The three nested-reraise tests now simulate nesting via _run_depth.

Also harden tests/test_tui_app.py render() with no_color=True so its
column-alignment assertions are deterministic under a forced-color environment
(FORCE_COLOR/CLICOLOR_FORCE) — unrelated to notify, but it was surfacing as a
spurious failure when the suite runs under a color-forcing wrapper.

Full suite: 2934 passed, 1 skipped (no FORCE_COLOR); trunk check --no-fix clean.
@pbean

pbean commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in b9dac20

Validated all seven findings from the review above against the code — all confirmed — and fixed them on this branch. Scope calls: fix finding 4 now, document finding 5.

Implementation defects

  1. Embedded-NUL crash (gates.py) — ✅ Fixed. Broadened the best-effort except to include ValueError. Confirmed a NUL in argv and in an env value both raise ValueError, and subprocess.SubprocessError is not a ValueError subclass, so it was escaping. gates.notify now truly never raises.
  2. pre_run pause bypasses the warning (engine.py) — ✅ Fixed. Moved _warn_desktop_notifier_inert() above _emit_run_boundary("pre_run"), so a plugin pause veto there can't skip the once-per-run warning + journal event.
  3. notify-send option injection (gates.py) — ✅ Fixed. Added a -- terminator before the title/message, so an option-shaped payload (--help, -t …) stays positional instead of being parsed by GLib.
  4. Off-main-thread nested-warning duplication (engine.py) — ✅ Fixed (chose to fix now). Replaced the signal-ownership derivation of _is_nested with a module-level contextvars run-depth counter (run()_run_inner() wrapper). Off-main-thread top-level runs (which own no signals) are now correctly depth-0 and warn; a nested auto-sweep — verified to run synchronously in-thread via sweep_factory — is depth>0 regardless of signal ownership. Signal ownership now governs only handler install + session teardown.

Verification gaps

  1. Native macOS/Windows delivery only mocked — 📝 Documented. Headless CI can't observe a real toast/notification (no macOS job), so the native paths stay unit-tested at the command-construction level; added a manual smoke-test note to the CHANGELOG and the gates.notify docstring.
  2. pwsh-only dispatch path — ✅ Test added.
  3. Warning policy matrix — ✅ Tests added for desktop=false suppression, file=false no-channel guidance, and the off-main-thread / nested paths exercised through Engine.run(). The three existing nested-reraise tests now simulate nesting via the run-depth counter.

The two candidates you dismissed (discarded nonzero notifier exits; the alleged AUMID mismatch) are correctly non-issues — no change.

Also folded in (unrelated)

Hardened tests/test_tui_app.py's render() helper with no_color=True. It builds a plain Rich Console, so under a color-forcing environment (FORCE_COLOR/CLICOLOR_FORCE) it emitted ANSI codes that shifted a column-alignment assertion in one validate-findings test. Now deterministic regardless of ambient color; unrelated to notify but was surfacing as a spurious failure.

Validation

Full suite 2934 passed, 1 skipped; trunk check --no-fix clean.

@pbean
pbean merged commit cb87176 into main Jul 24, 2026
10 checks passed
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.

notify.desktop = true silently does nothing on macOS and Windows

1 participant