Skip to content

Bridge Claude Code PushNotification tool into cmux notifications - #7385

Merged
lawrencecchen merged 11 commits into
mainfrom
feat-claude-push-notification-hook
Jul 6, 2026
Merged

lawrencecchen merged 11 commits into
mainfrom
feat-claude-push-notification-hook

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Claude Code has a PushNotification tool the model calls to proactively notify the user ("build failed", "needs your decision"). Inside cmux those pushes were silently swallowed: the tool delivers via a raw OSC desktop notification and never fires the Notification hook, while cmux suppresses raw OSC notifications on surfaces running a hook-integrated agent (suppressesRawTerminalNotification, https://github.com/manaflow-ai/cmux/blob/main/Sources/Workspace%2BPanelLifecycle.swift). Verified live: a PushNotification call reporting "Terminal notification sent" produced nothing in cmux list-notifications.

Fix: the wrapper's injected settings gain a PostToolUse hook with matcher PushNotification, calling a new cmux hooks claude push-notification handler that posts the tool's message through notify_target_async with the usual workspace/surface resolution, stale-session and nested-agent guards. The handler mirrors the tool's own delivery decision via the structured tool_response (localSent / disabledReason): a push the tool skipped (user active, channel disabled) is not duplicated, and a missing structured response (older clients) fails open so the message is never dropped. No lifecycle or status change, so a mid-turn push cannot flip a running pane to "Needs input".

Tests: tests/test_claude_wrapper_hooks.py now asserts the PostToolUse matcher and async bridge; new tests/test_claude_hook_push_notification.py (wired into ci.yml) drives the CLI handler against a capturing socket for sent, skipped, and fail-open payloads.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes user-visible notification delivery for Claude in cmux, but reuses existing hook routing/guards and is covered by new regression tests.

Overview
Fixes silent drops of Claude Code’s model-initiated PushNotification tool inside cmux: raw OSC delivery is suppressed on hook-integrated surfaces and the tool never hits the Notification hook, so proactive pushes never reached the notification store.

Adds an async PostToolUse hook (matcher PushNotification) in the Claude wrapper that calls cmux hooks claude push-notification. The new handler resolves workspace/surface like other Claude hooks, applies stale-session and nested-agent guards, posts via notify_target_async, and does not change agent lifecycle or flip a pane to “Needs input”. Bridging is suppressed only when tool_response.disabledReason is user_present or config_off; missing or partial responses fail open, and localSent is not used as the gate (cmux already swallows OSC either way). Message bodies are normalized and capped at 240 characters.

Several CLI helpers are widened to internal/func so the new extension can reuse them. CI, wrapper hook tests, a socket-capturing regression suite, and agent-hooks docs are updated.

Reviewed by Cursor Bugbot for commit 8a23881. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Bridges Claude Code’s PushNotification tool into cmux so model-initiated alerts show as cmux notifications. Delivers unless disabledReason is user_present or config_off, keeps pane lifecycle untouched, and normalizes + caps bodies at 240 chars.

  • Bug Fixes

    • Inject PostToolUse matcher PushNotification and add async cmux hooks claude push-notification.
    • Deliver unless disabledReason is user_present/config_off; ignore localSent; fail open on missing/partial tool_response (incl. JSON-null reason); breadcrumb empty payloads.
    • Resolve workspace/surface and post via notify_target_async with staleness and nested-agent guards; no “Needs input” flip.
    • Tests cover sent, skipped, fail-open, localSent=false without reason, and oversized body truncation; wired into CI; docs updated.
  • Refactors

    • Move handler to CMUXCLI+ClaudePushNotificationHook.swift; widen minimal helpers for reuse with no behavior change.

Written for commit 8a23881. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a push-notification subcommand to cmux claude-hook.
    • Bridged Claude Code PushNotification tool deliveries into cmux notifications via a new PostToolUse path.
  • Bug Fixes
    • Prevents duplicate or unintended notifications by suppressing only explicit skip cases.
    • Fails open for missing/partially structured hook responses to still show a best-effort notification.
  • Documentation
    • Updated agent hook docs to describe the PostToolUse push-notification bridge and skip behavior.
  • Tests / CI
    • Added a regression test covering delivered, skipped, and missing-response scenarios; CI now runs it.

Claude Code's PushNotification tool delivers via a raw OSC desktop
notification and never fires the Notification hook. cmux suppresses raw
OSC notifications on surfaces running a hook-integrated agent
(suppressesRawTerminalNotification), so inside cmux every
PushNotification was silently swallowed.

Add a PostToolUse hook (matcher PushNotification) to the wrapper's
injected settings and a `cmux hooks claude push-notification` handler
that posts the tool's message through notify_target_async. The handler
mirrors the tool's own delivery decision via tool_response.localSent
(skipped pushes stay skipped) and fails open when an older client omits
the structured response. No lifecycle/status change: a mid-turn push
must not flip a running pane to "Needs input".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 6, 2026 7:13am

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Claude PushNotification PostToolUse hook bridge to cmux, wires it into the generated wrapper hook config, updates tests and CI, and registers the new Swift source in the Xcode project.

Changes

PushNotification hook bridge

Layer / File(s) Summary
CLI push-notification hook handling
CLI/cmux.swift, CLI/CMUXCLI+ClaudePushNotificationHook.swift
Adds push-notification handling to the Claude hook dispatcher, helper extraction/bridge logic, help text, PostToolUse mapping, and the new hook implementation that sends notify_target_async.
Wrapper hook config and docs
Resources/bin/cmux-claude-wrapper, docs/agent-hooks.md
Adds a PostToolUse hook entry for the PushNotification matcher in generated HOOKS_JSON, updates inline documentation, and documents the bridging and suppression behavior.
Regression tests and CI wiring
tests/test_claude_hook_push_notification.py, tests/test_claude_wrapper_hooks.py, .github/workflows/ci.yml
Adds a new capture-server regression test for delivery scenarios, updates wrapper hook expectations for PostToolUse, and adds a CI invocation of the new test.
Project wiring
cmux.xcodeproj/project.pbxproj
Registers the new Swift source file in the Xcode project build inputs, group membership, and Sources build phase.

Estimated code review effort: 4 (Complex) | ~45 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Cmux Full Internationalization ❌ Error The new cmux claude-hook help line is a raw Swift string, not localized, and there’s no matching .xcstrings key for that changed user-facing copy. Move the claude-hook usage/help text to String(localized:defaultValue:) (or equivalent) and add a Resources/Localizable.xcstrings entry with translations for en/ja/ko/uk.
✅ Passed checks (24 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS: The PR adds plain value helpers and a CLI hook path; CMUXCLI is an unannotated struct, and no new @MainActor/Sendable/shared-mutable isolation issues appear.
Cmux Swift Blocking Runtime ✅ Passed The new push-notification Swift code adds routing only; it contains no semaphores, sleeps, main-queue sync, polling loops, or locks.
Cmux Browser Automation Off-Main ✅ Passed PR only changes Claude push-notification hook code; no browser.* commands, WebKit waits, or socket-worker routing are touched.
Cmux Expensive Synchronous Load ✅ Passed No expensive history loader was added; the new socket hook only does existing hook-state lookup and command dispatch, with no RestorableAgentSessionIndex.load/transcript scan.
Cmux Cache Substitution Correctness ✅ Passed PASS: The new push-notification path still does live workspace/surface resolution and freshness-gated staleness checks; no persistence/history/snapshot cache substitution was introduced.
Cmux No Hacky Sleeps ✅ Passed No new fixed sleeps/timers in runtime code; only bounded test harness waits and CI timeout config, which the repo rule allows.
Cmux Algorithmic Complexity ✅ Passed New push-notification path uses O(1) store lookups and a few single surface-list scans; no nested rescans or batch-size complexity was introduced.
Cmux Swift Concurrency ✅ Passed PASS: The new Swift hook handler is synchronous; no added DispatchQueue/Task/Combine/completion-handler async patterns appear in the touched Swift diff.
Cmux Swift @Concurrent ✅ Passed The new push-notification hook is synchronous, not actor-isolated, and the touched Swift files add no @concurrent or async boundary changes.
Cmux Swift File And Package Boundaries ✅ Passed PASS: the new 136-line Swift file is a focused hook extension, and the huge existing CLI file only got a tiny helper exposure tweak.
Cmux Swiftpm Lockfiles ✅ Passed Only CLI/CMUXCLI+ClaudePushNotificationHook.swift changed; there are no Package.swift, Package.resolved, .gitignore, workflow, or Xcode package-reference diffs.
Cmux Swift Logging ✅ Passed Only CLI-facing stdout prints were added in the new hook handler; no Logger/NSLog/debugPrint changes or ad hoc diagnostics were introduced.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error/recovery text leaks vendor/internal details; the new hook reuses the existing Claude Code title and only adds OK/bridge output.
Cmux Swiftui State Layout ✅ Passed The PR touches only CLI/CMUXCLI+ClaudePushNotificationHook.swift; no SwiftUI views, ObservableObject/@published, GeometryReader, or render-time state writes were introduced.
Cmux Architecture Rethink ✅ Passed The diff adds a required PostToolUse bridge with one CMUXCLI owner and no new timing/lock/observer hacks or split lifecycle ownership.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No touched Swift code adds or changes standalone windows; the PR only adds a hook handler file and related CLI logic, with no NSWindow/WindowGroup/close-shortcut changes.
Cmux Source Artifacts ✅ Passed PASS: the only actual changed path is a Swift source file under CLI/, not a generated log/temp/cache/build artifact, and no scratch dirs appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PASS: The push-notification hook lands in CLI/, not in any production **/Sources/** file, and no new test/debug seam was added in scoped production sources.
Cmux No Ambient Global State ✅ Passed The new push-notification logic is an instance method on CMUXCLI; helpers/types stay nested there, with no new free globals, singleton, or static-namespace surface.
Title check ✅ Passed The title clearly summarizes the main change: bridging Claude Code PushNotification into cmux notifications.
Description check ✅ Passed The description covers what changed and how it was tested, but it omits the template's Demo Video, Review Trigger, and Checklist sections.
✨ 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 feat-claude-push-notification-hook

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.

@greptile-apps

greptile-apps Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Bridges Claude Code's PushNotification tool into cmux notifications via a new PostToolUse hook, fixing a silent-swallow bug where raw OSC notifications are suppressed on hook-integrated surfaces and the Notification hook never fires for this tool.

  • Adds CMUXCLI+ClaudePushNotificationHook.swift with the handler; gates delivery only on disabledReason (user_present/config_off), ignores localSent, and fails open on missing or partial tool_response so no message is silently dropped.
  • Widens six private helpers in cmux.swift to internal so the new extension file can call them; no test seams introduced.
  • Adds seven regression cases covering sent, skipped, fail-open, JSON-null reason, localSent=false without reason, config_off, and oversized-body truncation, all wired into CI.

Confidence Score: 5/5

Safe to merge; the change is additive notification plumbing with no lifecycle or status mutations and conservative skip guards.

The new handler follows the established hook pattern exactly, reuses existing workspace/surface resolution and guards, and is covered by seven targeted regression tests now running in CI. The only rough edge is a misleading comment in the wrapper script — the code itself is correct.

Resources/bin/cmux-claude-wrapper — the inline comment about localSent being the skip gate is inaccurate and should be corrected to reference disabledReason.

Important Files Changed

Filename Overview
CLI/CMUXCLI+ClaudePushNotificationHook.swift New handler for PostToolUse/PushNotification bridge; correctly mirrors existing hook patterns with staleness and nested-agent guards, gates only on disabledReason (not localSent), and caps body at 240 chars.
CLI/cmux.swift Widens six private members to internal scope so the new extension file can call them; adds the push-notification case to the hook dispatch switch and the help text; all changes are production-path reuse, not test seams.
Resources/bin/cmux-claude-wrapper Adds PostToolUse/PushNotification hook entry to HOOKS_JSON; the surrounding comment incorrectly credits localSent as the skip gate when the implementation uses only disabledReason.
tests/test_claude_hook_push_notification.py Seven regression cases covering sent, user_present skip, config_off skip, missing tool_response fail-open, JSON-null disabledReason fail-open, localSent=false without reason (bridge), and oversized body truncation; test cwd fixed to neutral /tmp path.
tests/test_claude_wrapper_hooks.py Updates expected hook-key sets to include PostToolUse and asserts the PushNotification matcher + async flag are present.
.github/workflows/ci.yml Adds test_claude_hook_push_notification.py to the CI test run; change is minimal and correct.
cmux.xcodeproj/project.pbxproj Adds CMUXCLI+ClaudePushNotificationHook.swift to both the file references and build sources sections; no package dependency changes.
docs/agent-hooks.md Adds one paragraph documenting the PostToolUse PushNotification bridge and its skip behavior; accurate and concise.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CC as Claude Code
    participant Wrapper as cmux-claude-wrapper
    participant CLI as cmux hooks claude push-notification
    participant Socket as cmux daemon

    CC->>Wrapper: PushNotification tool call (PostToolUse hook)
    Note over Wrapper: async, timeout 10s
    Wrapper->>CLI: "stdin: JSON {tool_input.message, tool_response}"
    CLI->>CLI: claudePushNotificationMessage() — extract + normalize + cap 240
    alt empty message
        CLI-->>Wrapper: print OK (breadcrumb: empty)
    else "disabledReason == user_present or config_off"
        CLI-->>Wrapper: print OK (breadcrumb: skipped)
    else staleness / nested-agent guard fails
        CLI-->>Wrapper: print OK (breadcrumb: stale / nested-suppressed)
    else
        CLI->>Socket: notify_target_async workspaceId surfaceId payload
        Socket-->>CLI: OK
        CLI-->>Wrapper: print OK
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CC as Claude Code
    participant Wrapper as cmux-claude-wrapper
    participant CLI as cmux hooks claude push-notification
    participant Socket as cmux daemon

    CC->>Wrapper: PushNotification tool call (PostToolUse hook)
    Note over Wrapper: async, timeout 10s
    Wrapper->>CLI: "stdin: JSON {tool_input.message, tool_response}"
    CLI->>CLI: claudePushNotificationMessage() — extract + normalize + cap 240
    alt empty message
        CLI-->>Wrapper: print OK (breadcrumb: empty)
    else "disabledReason == user_present or config_off"
        CLI-->>Wrapper: print OK (breadcrumb: skipped)
    else staleness / nested-agent guard fails
        CLI-->>Wrapper: print OK (breadcrumb: stale / nested-suppressed)
    else
        CLI->>Socket: notify_target_async workspaceId surfaceId payload
        Socket-->>CLI: OK
        CLI-->>Wrapper: print OK
    end
Loading

Reviews (8): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
if let localSent = response["localSent"] as? Bool {
return localSent
}
return response["disabledReason"] == nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The fallback response["disabledReason"] == nil mishandles a JSON-null disabledReason. Swift's JSONSerialization converts JSON null to NSNull(), so a payload {"disabledReason": null} leaves response["disabledReason"] as Optional<Any>.some(NSNull()), which is not Swift nil. The comparison therefore returns false, suppressing the notification — the opposite of the intended fail-open behaviour when no meaningful disable reason is present. Narrowing to as? String == nil treats both absent keys and non-String values (including NSNull) as "no disable reason".

Suggested change
return response["disabledReason"] == nil
return response["disabledReason"] as? String == nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 352471f: only a string disabledReason marks a skip now (response["disabledReason"] as? String == nil), so NSNull fails open. Regression test added in fd054f1 (red on the pre-fix binary, green after).

— Claude Code

payload = {
"session_id": f"sess-{uuid.uuid4().hex}",
"hook_event_name": "PostToolUse",
"cwd": "/Users/lawrence/fun",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The cwd field is hardcoded to a developer-specific local path. This does not affect test correctness (cmux only uses cwd for context), but it leaks an author's home directory into the shared test corpus and will look odd in CI diffs. A neutral placeholder is a small, clean fix.

Suggested change
"cwd": "/Users/lawrence/fun",
"cwd": "/tmp/cmux-test-workspace",

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to a neutral /tmp/cmux-test-workspace cwd in fd054f1.

— Claude Code

Comment on lines +244 to +257
# 3. No structured tool_response (older client) -> fail open and bridge.
proc, commands, workspace_id, surface_id = run_push_notification_hook(
cli_path,
push_payload("fallback delivery", None),
)
if proc.returncode != 0:
print("FAIL: push-notification (fail-open) hook exited nonzero")
print(f"stdout={proc.stdout!r} stderr={proc.stderr!r} commands={commands!r}")
return 1
expected = f"notify_target_async {workspace_id} {surface_id} Claude Code||fallback delivery"
if [line for line in commands if line.startswith("notify_target_async ")] != [expected]:
print("FAIL: missing tool_response should fail open and bridge the message")
print(f"expected={expected!r} commands={commands!r}")
return 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing test case for disabledReason: null edge

The three existing cases cover localSent: true, localSent: false + disabledReason: string, and no tool_response. There is no case for tool_response present with localSent absent and disabledReason explicitly null in JSON. Due to the NSNull issue in claudePushNotificationWasDelivered, this payload would currently suppress the notification rather than fail open. Adding a fourth assertion here would pin the correct behaviour and catch a regression if the Swift-side fix is ever reverted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in fd054f1: case 4 sends tool_response with localSent absent and disabledReason: null, and asserts the message is bridged. Verified red against the pre-fix binary, green after 352471f.

— Claude Code

@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: 1

🤖 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 `@tests/test_claude_hook_push_notification.py`:
- Line 36: The test is auto-discovering and executing a binary from
world-writable /tmp via the candidate selection logic and later subprocess
execution, which should be avoided. Update the candidate handling in the test
helper and the execution path in the same test to stop globbing /tmp for cmux,
and instead require an explicit binary path such as CMUX_CLI_BIN (or another
explicitly provided build output) before running anything. Keep the existing
executable check, but only on user-supplied paths, and make the test fail fast
if no explicit binary is provided.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2b58a933-727a-42af-83a2-51c027c44eac

📥 Commits

Reviewing files that changed from the base of the PR and between b3e791c and 3af483e.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • CLI/cmux.swift
  • Resources/bin/cmux-claude-wrapper
  • docs/agent-hooks.md
  • tests/test_claude_hook_push_notification.py
  • tests/test_claude_wrapper_hooks.py

Comment thread tests/test_claude_hook_push_notification.py Outdated
Judge note: the missing-message early return was the only guard path
without a breadcrumb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 9ec064cacf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

lawrencecchen and others added 3 commits July 5, 2026 18:53
workflow-guard-tests failed the Swift file length budget: cmux.swift was
exactly at budget before this branch and the new handler added 104 lines.
Pure code motion into CLI/CMUXCLI+ClaudePushNotificationHook.swift (under
the 500-line tracking threshold); cmux.swift is back to its budgeted
34499 lines. Widened only the helpers the moved code calls from private
to internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JSONSerialization maps JSON null to NSNull, not Swift nil, so
claudePushNotificationWasDelivered's disabledReason presence check
suppresses a payload with an explicit null reason instead of bridging
it. Also drop the /tmp binary glob from the test's CLI resolution
(world-writable dir; CI passes CMUX_CLI_BIN) and use a neutral cwd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only a string disabledReason marks a skipped push; NSNull and other
non-string values fail open so the message is never silently dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CLI/cmux.swift (1)

15821-15839: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Static claude-hook --help usage text wasn't updated for the new push-notification subcommand.

The dynamic help printed by case "help", "--help", "-h": (line 23787-23789) now lists push-notification, but subcommandUsage("claude-hook") — shown for cmux claude-hook --help before socket dispatch (see dispatchSubcommandHelp) — still only documents session-start|active|stop|idle|notification|notify|prompt-submit. This leaves the two help surfaces inconsistent and push-notification undiscoverable via --help.

📝 Proposed fix
-            Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags]
+            Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit|push-notification> [flags]

             Hook for Claude Code integration. Reads JSON from stdin.

             Subcommands:
               session-start   Signal that a Claude session has started
               active          Alias for session-start
               stop            Signal that a Claude session has stopped
               idle            Alias for stop
               notification    Forward a Claude notification
               notify          Alias for notification
               prompt-submit   Clear notification and set Running on user prompt
+              push-notification  Forward a PushNotification tool call
🤖 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 `@CLI/cmux.swift` around lines 15821 - 15839, The static help text returned by
subcommandUsage("claude-hook") is missing the new push-notification subcommand,
so update the claude-hook usage string to match the dynamic help shown in
dispatchSubcommandHelp and the case "help", "--help", "-h" path. Make sure the
subcommand list in the claude-hook branch includes push-notification alongside
the existing session-start, active, stop, idle, notification, notify, and
prompt-submit entries so both help surfaces stay consistent.
🤖 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.

Outside diff comments:
In `@CLI/cmux.swift`:
- Around line 15821-15839: The static help text returned by
subcommandUsage("claude-hook") is missing the new push-notification subcommand,
so update the claude-hook usage string to match the dynamic help shown in
dispatchSubcommandHelp and the case "help", "--help", "-h" path. Make sure the
subcommand list in the claude-hook branch includes push-notification alongside
the existing session-start, active, stop, idle, notification, notify, and
prompt-submit entries so both help surfaces stay consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c7711fbb-4134-4cb0-a641-f5f477a1dbe4

📥 Commits

Reviewing files that changed from the base of the PR and between 9ec064c and f5b0471.

📒 Files selected for processing (3)
  • CLI/CMUXCLI+ClaudePushNotificationHook.swift
  • CLI/cmux.swift
  • cmux.xcodeproj/project.pbxproj

lawrencecchen and others added 4 commits July 5, 2026 19:19
Codex review P1: gating the bridge on tool_response.localSent makes it
inert whenever Claude's local terminal channel is unavailable or
suppressed (mobile-only delivery, or a client honoring the wrapper's
notifications_disabled for the tool). Only explicit user-facing skip
reasons (user_present, config_off) should suppress the cmux bridge.
Also pins config_off as a skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bridge unless disabledReason is user_present or config_off. localSent
no longer decides anything: cmux swallows the tool's raw OSC delivery
regardless of the local-channel outcome, so keying on it made the
bridge inert exactly when the local channel is unavailable (codex
review P1). Unknown reasons and unstructured responses still fail open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex review P2: the bridge forwarded tool_input.message unbounded into
the notification store, unlike every other hook path which normalizes
and caps bodies. Pin the 240-char normalized truncation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same bound as every other claude hook body (message-key limit in
claudeHookCompactFieldLimit); uses the shared normalizedSingleLine +
truncate helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CLI/CMUXCLI+ClaudePushNotificationHook.swift (1)

17-26: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale comment contradicts the actual bridging logic.

This comment says bridging mirrors tool_response.localSent, but claudePushNotificationShouldBridge (lines 105-127) deliberately does the opposite — it ignores localSent and only suppresses on explicit disabledReason values (user_present/config_off), per the well-documented rationale directly below it. This looks like a leftover from before the routing refinement described in the commit history; leaving it risks a future maintainer "fixing" the code back to the localSent-keyed behavior that was intentionally removed.

📝 Proposed fix
-        // PostToolUse bridge for Claude Code's PushNotification tool. The
-        // tool delivers through a raw OSC desktop notification, and cmux
-        // deliberately drops raw OSC notifications from surfaces running a
-        // hook-integrated agent (they would duplicate hook notifications),
-        // so without this bridge every PushNotification is silently
-        // swallowed inside cmux. The tool's own Notification hook never
-        // fires for it. Mirror the tool's delivery decision: bridge exactly
-        // when the tool reports its terminal notification as sent
-        // (tool_response.localSent); fail open when an older client omits
-        // the structured response.
+        // PostToolUse bridge for Claude Code's PushNotification tool. The
+        // tool delivers through a raw OSC desktop notification, and cmux
+        // deliberately drops raw OSC notifications from surfaces running a
+        // hook-integrated agent (they would duplicate hook notifications),
+        // so without this bridge every PushNotification is silently
+        // swallowed inside cmux. The tool's own Notification hook never
+        // fires for it. Bridge unless tool_response.disabledReason is an
+        // explicit user-facing skip ("user_present"/"config_off"); fail
+        // open when an older client omits the structured response — see
+        // claudePushNotificationShouldBridge for the full rationale.
🤖 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 `@CLI/CMUXCLI`+ClaudePushNotificationHook.swift around lines 17 - 26, The
comment above the Claude PushNotification bridge is stale and no longer matches
the logic in claudePushNotificationShouldBridge. Update the comment near
CMUXCLI+ClaudePushNotificationHook to describe the current behavior: bridging is
based on explicit disabledReason values like user_present and config_off, not on
tool_response.localSent. Keep the wording aligned with the rationale in
claudePushNotificationShouldBridge so future readers don’t infer the old
localSent-based routing.
🤖 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.

Outside diff comments:
In `@CLI/CMUXCLI`+ClaudePushNotificationHook.swift:
- Around line 17-26: The comment above the Claude PushNotification bridge is
stale and no longer matches the logic in claudePushNotificationShouldBridge.
Update the comment near CMUXCLI+ClaudePushNotificationHook to describe the
current behavior: bridging is based on explicit disabledReason values like
user_present and config_off, not on tool_response.localSent. Keep the wording
aligned with the rationale in claudePushNotificationShouldBridge so future
readers don’t infer the old localSent-based routing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c9359da2-0c99-460e-85f4-aed2e120dd34

📥 Commits

Reviewing files that changed from the base of the PR and between 352471f and 1413ad8.

📒 Files selected for processing (2)
  • CLI/CMUXCLI+ClaudePushNotificationHook.swift
  • tests/test_claude_hook_push_notification.py

The queued CI run for stale head 9ec064c jammed the branch
concurrency group; pushes 352471f..625dd8f never got runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fication-hook

# Conflicts:
#	cmux.xcodeproj/project.pbxproj
@lawrencecchen
lawrencecchen merged commit 10a418e into main Jul 6, 2026
68 of 70 checks passed
@lawrencecchen
lawrencecchen deleted the feat-claude-push-notification-hook branch July 6, 2026 07:15
mochiexists pushed a commit to mochiexists/cmux-mochi that referenced this pull request Jul 7, 2026
…aflow-ai#7385)

* Bridge Claude Code PushNotification tool into cmux notifications

Claude Code's PushNotification tool delivers via a raw OSC desktop
notification and never fires the Notification hook. cmux suppresses raw
OSC notifications on surfaces running a hook-integrated agent
(suppressesRawTerminalNotification), so inside cmux every
PushNotification was silently swallowed.

Add a PostToolUse hook (matcher PushNotification) to the wrapper's
injected settings and a `cmux hooks claude push-notification` handler
that posts the tool's message through notify_target_async. The handler
mirrors the tool's own delivery decision via tool_response.localSent
(skipped pushes stay skipped) and fails open when an older client omits
the structured response. No lifecycle/status change: a mid-turn push
must not flip a running pane to "Needs input".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add breadcrumb on empty push-notification payload

Judge note: the missing-message early return was the only guard path
without a breadcrumb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move push-notification hook handler out of cmux.swift

workflow-guard-tests failed the Swift file length budget: cmux.swift was
exactly at budget before this branch and the new handler added 104 lines.
Pure code motion into CLI/CMUXCLI+ClaudePushNotificationHook.swift (under
the 500-line tracking threshold); cmux.swift is back to its budgeted
34499 lines. Widened only the helpers the moved code calls from private
to internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing test: JSON-null disabledReason must fail open

JSONSerialization maps JSON null to NSNull, not Swift nil, so
claudePushNotificationWasDelivered's disabledReason presence check
suppresses a payload with an explicit null reason instead of bridging
it. Also drop the /tmp binary glob from the test's CLI resolution
(world-writable dir; CI passes CMUX_CLI_BIN) and use a neutral cwd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat JSON-null disabledReason as fail-open in push-notification bridge

Only a string disabledReason marks a skipped push; NSNull and other
non-string values fail open so the message is never silently dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing test: localSent=false without a skip reason must bridge

Codex review P1: gating the bridge on tool_response.localSent makes it
inert whenever Claude's local terminal channel is unavailable or
suppressed (mobile-only delivery, or a client honoring the wrapper's
notifications_disabled for the tool). Only explicit user-facing skip
reasons (user_present, config_off) should suppress the cmux bridge.
Also pins config_off as a skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gate push-notification bridge on skip reason, not localSent

Bridge unless disabledReason is user_present or config_off. localSent
no longer decides anything: cmux swallows the tool's raw OSC delivery
regardless of the local-channel outcome, so keying on it made the
bridge inert exactly when the local channel is unavailable (codex
review P1). Unknown reasons and unstructured responses still fail open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing test: oversized push-notification body must be truncated

Codex review P2: the bridge forwarded tool_input.message unbounded into
the notification store, unlike every other hook path which normalizes
and caps bodies. Pin the 240-char normalized truncation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Normalize and cap push-notification message at 240 chars

Same bound as every other claude hook body (message-key limit in
claudeHookCompactFieldLimit); uses the shared normalizedSingleLine +
truncate helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger checks on current head

The queued CI run for stale head 9ec064c jammed the branch
concurrency group; pushes 352471f..625dd8f never got runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 10a418e)
@lawrencecchen
lawrencecchen restored the feat-claude-push-notification-hook branch July 18, 2026 10:24

This branch was successfully deployed

1 active deployment
Preview – cmux — 8a238814 Deployed Jul 6, 2026 by vercel[bot]
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.

1 participant