Skip to content

Inline notification replies (macOS + iOS) with schema-driven reply shapes and a notification debug mode - #8670

Merged
azooz2003-bit merged 16 commits into
mainfrom
feat-notif-inline-reply
Aug 9, 2026
Merged

azooz2003-bit merged 16 commits into
mainfrom
feat-notif-inline-reply

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Adds iMessage-style inline replies to cmux notifications, driven by an explicit reply-shape schema so future notification kinds degrade safely to open-only.

macOS. TerminalNotification gains replyShape (none|text), derived from the agent hook category (turn-complete and idle-reminder are text-replyable) or set explicitly via reply_shape on the notification.create* socket verbs and cmux notify --reply. Text-shape banners use a new category whose UNTextInputNotificationAction routes the typed text through surface.send_text plus Return into the agent's terminal, then marks the notification read; empty or failed replies fall back to the existing open path. Exit-plan banners gain a fourth "Revise…" text action that resolves the parked hook with exitPlan(.manual, feedback:). AskUserQuestion banners with one single-select question and at most 4 options get a per-request dynamic category with one button per option (labels from the agent payload) plus an "Other…" free-text action; multi-select, multi-question, or larger option sets keep the current open-only banner. Dynamic categories are always unioned with the static set, pruned against live waiters at mint time, and removed when the request resolves.

iOS. The Mac forwards replyShape in phone pushes; the web relay picks APNs category cmux.terminal.reply for text-shape pushes (unknown shapes coerce to the plain category). The iOS app registers a reply category with a text action; a submitted reply is parked (latest-wins, 120 s TTL) until the store, target Mac, and RPC channel are ready, then sent via explicit-target terminal.input plus Return without changing navigation or selection.

Debug mode. DEBUG-only debug.notification.mode / debug.notification.emit socket verbs and a matching Debug-menu section emit every notification kind (8 pane-banner kinds, 7 Feed decision variants including every fallback case) through the real store and feed.push pipelines, so all presentations and reply paths are testable on demand. Feed debug items use real waiters and time out after 300 s.

Verification: CmuxNotifications 73 tests, CMUXAgentLaunch 241, CmuxControlSocket 277, web 31 tests plus typecheck, macOS compile, tagged build nreply, and live socket round-trips of all three Feed decision replies against debug-emitted items. Live banner-click verification on macOS and phone-side reply are pending dogfood. Follow-up (Feed decision pushes to iPhone plus a notification content extension for multi-select) is specced separately.

🤖 Generated with Claude Code


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


Summary by cubic

Adds inline text replies to terminal notifications on macOS and iOS with a schema-driven replyShape, plus a debug mode and diagnostics to test and inspect notification behavior. Improves reliability with merged dynamic categories on (re)install, live retargeting, bounded iOS reply retries (including channel-down cases), and clearer exit‑plan handling.

  • New Features

    • macOS: TerminalNotification.replyShape (none|text) from agent category or via socket reply_shape and CLI cmux notify --reply. Text-reply banners use a new category/action; replies live‑retarget to the resolved surface owner and fail closed if missing; exit‑plan adds a “Revise…” text action; eligible single‑select AskUserQuestion (≤4 options) gets inline buttons plus “Other…”.
    • iOS: Mac forwards replyShape; web switches APNs category between CMUX_APNS_REPLY_CATEGORY and CMUX_APNS_CATEGORY. The app registers both with localized “Reply/Send/placeholder”, parks replies (latest wins, 120s TTL), retries failed sends and channel‑down cases after 5s, re‑parks on failure, and ensures a newer reply mid‑send still wins. Adds a DEBUG-only Settings button to schedule a local reply notification.
    • Web/CLI: Push payload includes replyShape; route policy parses it; tests cover category selection. notification.create* accept reply_shape; cmux notify --reply enables text replies.
    • Debug/Diagnostics: debug.notification.mode, debug.notification.emit, and debug.notification.status exercise all kinds and report system settings. Debug Macs route push via shared staging by default with CMUX_PUSH_API_BASE_URL override; per‑attempt push logs include host, HTTP status, byte count, and classification.
  • Bug Fixes

    • Feed questions: All CMUXFeedQuestion.* category get→set round trips serialize on one MainActor chain and use a bounded notificationCategories() read; launch/(re)install now merges existing categories so active question buttons aren’t stripped.
    • Exit‑plan: An empty “Revise…” reply opens the app at the card instead of approving.
    • macOS/iOS: Inline replies retarget safely, re‑park on failure, and retry when the RPC channel is unavailable; unknown replyShape degrades to open‑only.
    • Question parsing: WorkstreamQuestionPrompt.parse accepts nested and legacy shapes.
    • Control socket: ControlNotificationContext entrypoints default replyShapeWire to nil for legacy callers/tests.
    • Push logging: Fixed a nonisolated access in per‑attempt delivery logging and improved non‑HTTP handling.

Written for commit ea468f9. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added inline text replies to terminal notifications on macOS and iOS.
    • Added CLI support for sending reply-enabled notifications with --reply.
    • Added interactive Feed notification responses, including option selection, “Other…” replies, and exit-plan feedback.
    • Added localized reply controls and prompts in English and Japanese.
    • Added notification diagnostics for testing and troubleshooting in debug builds.
  • Bug Fixes
    • Improved compatibility with nested and legacy question formats.
    • Invalid notification reply settings now safely fall back to standard notifications.

Note

High Risk
Touches notification delivery, terminal input routing, cloud push payloads, and relay authorization boundaries across macOS and iOS; incorrect retargeting or category merge could mis-deliver agent input or strip live notification actions.

Overview
Adds inline text replies to terminal notifications end-to-end, driven by a replyShape schema (none / text) so unsupported kinds stay open-only.

macOS stores replyShape on notifications (from agent categories like turn-complete / idle-reminder, or explicit reply_shape on control socket / cmux notify --reply). Text-reply banners use a new notification category; typed text goes to the terminal via surface.send_text plus Return, with live surface retargeting and open-on-empty/failed reply. Feed exit-plan adds a “Revise…” text action; eligible single-select questions (≤4 options) get per-request dynamic categories with option buttons and “Other…”, serialized mint/cancel so categories aren’t clobbered on reinstall.

iOS registers reply APNs categories, parks submitted replies until Mac/workspace/surface/RPC are ready, sends via explicit terminal.input without changing UI selection, and retries transient failures. Phone push carries replyShape; Debug Macs default push relay to staging via pushAPIBaseURL.

DEBUG adds socket/menu notification emitters and debug.notification.* verbs to exercise all notification kinds through real pipelines.

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

@coderabbitai

coderabbitai Bot commented Jul 22, 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

The PR adds inline terminal replies for macOS and iOS notifications, propagates reply shape through terminal and APNs payloads, adds inline Feed question actions, centralizes question parsing, and adds DEBUG notification diagnostics and emission controls.

Changes

Notification reply contracts and mobile delivery

Layer / File(s) Summary
Reply contracts and pending state
Packages/iOS/CmuxMobileShell/..., Packages/iOS/CmuxMobileShellUI/..., Packages/macOS/CmuxNotifications/...
Adds terminal reply and explicit terminal-input APIs, reply-shape types, notification identifiers, pending-reply state, expiry handling, and localized reply strings.
macOS and iOS reply handling
Packages/macOS/CmuxNotifications/..., Sources/AppDelegate*, ios/cmux/CmuxAppDelegate.swift
Adds text-input notification actions, terminal routing, feed reply handling, read marking, fallback navigation, and deferred mobile reply delivery.

Reply-shape propagation

Layer / File(s) Summary
Reply-shape request and storage flow
CLI/cmux.swift, Packages/macOS/CmuxControlSocket/..., Sources/TerminalController*, Sources/TerminalNotification*
Adds --reply, forwards reply_shape, stores reply shape through policy and queue paths, and selects the native text-reply notification category.
Cloud and APNs payload flow
Sources/AgentNotificationDelivery.swift, Sources/Cloud/..., web/services/apns/..., web/tests/apns.test.ts
Carries validated replyShape values through notification payloads and selects the APNs reply category for text replies.

Feed questions and parsing

Layer / File(s) Summary
Question parsing and inline actions
Packages/macOS/CMUXAgentLaunch/..., Sources/Feed/FeedCoordinator.swift
Normalizes nested and legacy question JSON, registers per-request question categories, supports option and “Other…” actions, and removes stale categories on cancellation. Tests cover both parser formats.

Debug notification tooling

Layer / File(s) Summary
DEBUG notification controls
Sources/NotificationDebug*, Sources/TerminalController*, Sources/IrohTransportDebugMenuButtons.swift, cmux.xcodeproj/project.pbxproj, Resources/Localizable.xcstrings
Adds notification status diagnostics, debug-mode controls, synthetic notification emission, localized labels, and project integration.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

  • manaflow-ai/cmux#8549: Modifies TerminalNotificationCallerResolver and caller-target resolution.
  • manaflow-ai/cmux#9630: Modifies CmuxNotifications notification infrastructure and UserNotificationCenterConfiguring.

Suggested reviewers: austinywang, lawrencecchen


Important

Pre-merge checks failed

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

❌ Failed checks (7 errors, 1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error The PR adds a semaphore timeout in DEBUG app source notificationDebugStatus and a detached thread calling blocking ingestBlocking; both are non-test Swift synchronization additions. Return notification status from the settings callback or async continuation, and drive debug Feed completion through an async signal/state transition instead of a detached blocking waiter.
Cmux Expensive Synchronous Load ❌ Error FeedCoordinator adds synchronous JSONSerialization of unbounded agent toolInputJSON in WorkstreamQuestionPrompt.parse, called from @MainActor notification handling for feed.push. Parse toolInputJSON off-main with a bounded parser, or reuse a cached parsed WorkstreamItem payload before returning to MainActor for notification setup.
Cmux Algorithmic Complexity ❌ Error Sources/Feed/FeedCoordinator.swift:1288-1292 builds live IDs, then calls Array.contains inside current.filter; with C categories and W waiters this is O(C×W), potentially O(W²). Keep liveCategoryIds as a Set before filtering. This makes category reconciliation O(C+W) instead of rescanning all live IDs for each category.
Cmux Swift Package Boundaries ❌ Error FeedCoordinator adds production dynamic AskUserQuestion category policy, pruning, and serialized UserNotifications mutation in the app target instead of CmuxNotifications. Extract the dynamic question-category coordinator into CmuxNotifications, exposing a small FeedQuestionNotificationCategoryCoordinator or policy protocol; keep FeedCoordinator waiter and AppDelegate wiring.
Cmux Swift Logging ❌ Error The PR adds file-scoped Logger constants as plain private let in MainActor-bound mobile code, not the required nonisolated private let form. Declare explicitTerminalInputLog and mobilePushLog as nonisolated private let; keep the existing private redaction on terminal identifiers and errors.
Cmux Architecture Rethink ❌ Error Sources/NotificationDebugTarget.swift adds DispatchSemaphore and a 3-second wait in the DEBUG socket status path, blocking a socket worker on an asynchronous notification callback. Make the UNUserNotificationCenter callback the sole source of truth: expose async status and await it in debug.notification.status. Remove the semaphore and timeout to prevent stalls, stale fallback, and callback races.
Cmux No Ambient Global State ❌ Error Sources/NotificationDebugEmitter.swift:9 adds static let shared for a runtime emitter with mutable isModeEnabled at line 30; this is a new ambient singleton. Move emitter state and behavior to a constructable NotificationDebugEmitter owned by AppDelegate, then inject it into debug socket handlers and NotificationDebugMenuButtons.
Docstring Coverage ⚠️ Warning Docstring coverage is 21.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Cmux Full Internationalization ❓ Inconclusive placeholder2 placeholder2
✅ Passed checks (16 passed)
Check name Status Explanation
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 Production UI state is explicitly @MainActor, background callbacks hop to MainActor, and new Sendable models are value-only; no new shared mutable Sendable reference or implicit async service proto...
Cmux Browser Automation Off-Main ✅ Passed The PR adds no browser automation routing changes; policy and policy-test files are unchanged, while existing browser waits use the socket-worker router and have coverage.
Cmux Cache Substitution Correctness ✅ Passed No production hunk substitutes a fresh read with a cache; category paths use direct notification-center reads with serialized updates, and iOS reply parking is transient with a 120-second TTL.
Cmux No Hacky Sleeps ✅ Passed The PR adds only APNs schema/parsing changes in TypeScript and tests; the complete diff adds no sleep, timer, polling, or fixed-delay synchronization.
Cmux Swift Concurrency ✅ Passed The Swift diff adds no background Dispatch queues, Combine, or internal completion APIs; Tasks are main-actor SwiftUI/OS callback hops, and Feed category updates use a stored Task chain.
Cmux Swift @Concurrent ✅ Passed New async reply paths are actor-isolated UI coordination; category tasks explicitly use @MainActor, and no changed function adds nonisolated async work or invalid @concurrent.
Cmux Swiftpm Lockfiles ✅ Passed Feature diff only adds three Swift source files to cmux.xcodeproj; it changes no SwiftPM package references, Package.swift, Package.resolved, or package .gitignore rules.
Cmux User-Facing Error Privacy ✅ Passed New user-facing copy is generic; CLI --reply uses the existing safe V2 formatter, and added debug errors/status are DEBUG-only without vendor names, secrets, raw errors, or payload dumps.
Cmux Swiftui State Layout ✅ Passed The new DEBUG SwiftUI menu adds no ObservableObject/@published state, GeometryReader, lazy/list row store reference, or render-time state mutation; existing SwiftUI changes are incidental menu comp...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR adds only a DEBUG SwiftUI menu/view, with no standalone window or close-shortcut code; scripts/lint_auxiliary_window_close_shortcuts.py passes.
Cmux Source Artifacts ✅ Passed The PR-side diff contains 50 source, test, localization, and project-config paths; no artifact directories, binary files, or generated artifact extensions were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed New DEBUG code is isolated in dedicated NotificationDebug*.swift files with real DEBUG menu/socket callers; no test-only accessor, TestSeam member, or visibility-wrapper pattern was added.
Title check ✅ Passed The title clearly and concisely summarizes the main change: inline notification replies across macOS and iOS with schema-driven reply shapes and debug support.
Description check ✅ Passed The description clearly explains the changes and verification, although it omits the template's Demo Video, Review Trigger, and Checklist sections.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-notif-inline-reply

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 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds iMessage-style inline notification replies to cmux on macOS and iOS, driven by a TerminalNotificationReplyShape (none | text) schema propagated from agent category, socket verbs, and the CLI. Exit-plan banners gain a "Revise…" text action, and single-select AskUserQuestion notifications (≤4 options) get per-request dynamic categories with one button per option. iOS parks replies up to 120 s until the store, workspace, and RPC channel are all ready, then sends via terminal.input. A #if DEBUG debug mode lets developers emit every notification kind and Feed variant on demand.

  • Reply shape schema: TerminalNotificationReplyShape is introduced in CmuxNotifications, propagated through TerminalNotification, TerminalNotificationPolicyRequest, the mutation bus, the push payload, and the web APNs layer, so reply affordances degrade safely to open-only for any unknown shape.
  • Dynamic per-request categories: FeedCoordinator mints a CMUXFeedQuestion.{requestId} category per eligible question notification, unions it with the live category set, and removes it on resolution; configureUserNotifications now fetches current categories before installing static ones to preserve any live dynamic categories across restarts.
  • iOS pending-reply state machine: PendingReplyState + PendingReplyDecision model the park-until-ready lifecycle with a pure decision function, making the retry logic testable without UIKit or a live store.

Confidence Score: 5/5

Safe to merge. No new correctness bugs introduced; the two pre-existing concerns (dynamic category race and debug seam) were flagged in prior review threads and are unchanged in this iteration.

The reply-shape schema is conservative (unknown values coerce to open-only), the macOS surface.send_text path uses the documented mainThreadCallable route and correctly reads ok:true from the custom protocol response, the iOS pending-reply state machine is well-tested and pure, and all string catalog additions carry both en and ja translations. The only new finding is a dead .ready branch in the iOS applyPendingReplyIfReady initial evaluate call — a clarity concern, not a runtime bug.

Prior-thread concerns remain open in Sources/Feed/FeedCoordinator.swift (dynamic category cleanup race in cancelNotification) and Sources/TerminalNotificationCallerResolver.swift (widened stringParam/boolParam visibility plus #if DEBUG debug seam). Neither is introduced or worsened by this iteration.

Important Files Changed

Filename Overview
Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift Adds terminal text-reply category, exit-plan Revise… text action, and routing for inline question-option and reply actions. Also switches configureUserNotifications to union-with-existing categories asynchronously to preserve live dynamic categories across restarts. The async category registration leaves a small window between delegate install and category write, and the cancelNotification category cleanup runs setNotificationCategories directly in a getNotificationCategories callback without a MainActor hop (race noted in prior review thread).
Sources/Feed/FeedCoordinator.swift Adds per-request dynamic notification categories (CMUXFeedQuestion.{requestId}) for eligible single-select questions (≤4 options), minted atomically with the notification. Category cleanup in cancelNotification uses a direct getNotificationCategories callback without MainActor dispatch, creating a race with registerQuestionCategoryAndAddIfStillAwaiting (flagged in prior review thread).
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift Adds handleReply() and applyPendingReplyIfReady() for iOS inline notification replies, backed by PendingReplyState with 120s TTL. Has one dead .ready branch in the initial evaluate() call that silently drops replies if the invariant ever breaks (P2).
Sources/TerminalNotificationCallerResolver.swift Adds reply_shape parsing from socket params (correct). Also adds a #if DEBUG notificationDebugCallerTarget() method to a production Sources/ file, widening stringParam/boolParam from private to internal — a test/debug seam violation flagged in the prior review thread.
Sources/AppDelegate+NotificationDeliverySeams.swift Adds NotificationTerminalReplying conformance to NotificationDeliverySeamAdapter. The sendReply impl constructs a surface.send_text socket payload and calls handleSocketLine on main, which is the documented mainThreadCallable path. Success is correctly detected via the ok:true field in the custom protocol response.
Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift New enum defining the reply-shape schema. Unknown/absent wire values safely coerce to .none; forAgentCategory() correctly restricts text replies to turn-complete and idle-reminder categories only.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift Clean value-type state machine for the park-until-ready lifecycle with pure evaluate() decision function. Well-tested by PendingReplyStateTests.
web/services/apns/payload.ts Adds replyShape-based APNs category selection: text → CMUX_APNS_REPLY_CATEGORY, all other values → CMUX_APNS_CATEGORY. Clean with tests covering all branches.
Sources/NotificationDebugEmitter.swift New #if DEBUG singleton for emitting any notification kind through the real store/Feed pipelines. Properly isolated to debug builds. Uses Thread.detachNewThread for ingestBlocking(waitTimeout: 300), which is appropriate since the call is genuinely blocking.
ios/cmux/CmuxAppDelegate.swift Routes UNTextInputNotificationResponse with cmux.reply actionIdentifier to pushCoordinator.handleReply(), then handleDismiss(). Does not navigate or change selection. Correctly guarded by replyText non-empty check.

Sequence Diagram

sequenceDiagram
    participant User
    participant iOS_Notif as iOS Notification Center
    participant CmuxAppDelegate as CmuxAppDelegate (iOS)
    participant MobilePushCoordinator
    participant PendingReplyState
    participant MobileShellStore
    participant Mac_Web as Web Relay (APNs)
    participant Mac_TerminalController as TerminalController (Mac)
    participant TerminalNotificationStore
    participant NotificationDeliveryCoordinator
    participant NotificationCenter as UNUserNotificationCenter (Mac)

    Note over Mac_TerminalController,TerminalNotificationStore: macOS notification delivery
    Mac_TerminalController->>TerminalNotificationStore: deliverNotificationSynchronously(replyShape:.text)
    TerminalNotificationStore->>NotificationCenter: schedule with textReplyCategoryIdentifier
    TerminalNotificationStore->>Mac_Web: PhonePushPayload(replyShape:"text")
    Mac_Web->>iOS_Notif: APNs push (category: cmux.terminal.reply)

    Note over User,MobileShellStore: iOS inline reply flow
    User->>iOS_Notif: Types reply text, taps Send
    iOS_Notif->>CmuxAppDelegate: didReceive(UNTextInputNotificationResponse)
    CmuxAppDelegate->>MobilePushCoordinator: handleReply(text, workspaceId, surfaceId, macDeviceId)
    MobilePushCoordinator->>PendingReplyState: park(PendingReply)
    MobilePushCoordinator->>MobilePushCoordinator: applyPendingReplyIfReady()
    MobilePushCoordinator->>PendingReplyState: evaluate(isTargetReachable, isChannelAvailable)
    alt All prerequisites met
        PendingReplyState-->>MobilePushCoordinator: .ready(reply)
        MobilePushCoordinator->>MobileShellStore: sendTerminalInput(text+CR, workspaceID, terminalID)
    else Prerequisites not met
        PendingReplyState-->>MobilePushCoordinator: .waiting
        Note over MobilePushCoordinator: Retried on bind(store:) or workspacesDidChange()
    end

    Note over User,NotificationDeliveryCoordinator: macOS inline reply flow
    User->>NotificationCenter: Types reply, taps Send (terminal.reply action)
    NotificationCenter->>NotificationDeliveryCoordinator: userNotificationCenter didReceive
    NotificationDeliveryCoordinator->>Mac_TerminalController: sendReply(text, tabId, surfaceId) via surface.send_text
    Mac_TerminalController-->>NotificationDeliveryCoordinator: ok:true / ok:false
    alt Send succeeded
        NotificationDeliveryCoordinator->>TerminalNotificationStore: markNotificationRead(id)
    else Send failed
        NotificationDeliveryCoordinator->>Mac_TerminalController: openTerminalNotification (fallback)
    end
Loading

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

Comment thread Sources/Feed/FeedCoordinator.swift Outdated
Comment on lines 1121 to 1131
let center = UNUserNotificationCenter.current()
center.removePendingNotificationRequestsOffMain(withIdentifiers: [identifier])
center.removeDeliveredNotificationsOffMain(withIdentifiers: [identifier])
let categoryId = "CMUXFeedQuestion.\(requestId)"
center.getNotificationCategories { current in
let categories = Set(current.filter { $0.identifier != categoryId })
center.setNotificationCategories(categories)
}
}
}

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.

P1 Dynamic category cleanup races with registration

cancelNotification's getNotificationCategories callback calls setNotificationCategories directly on whatever queue UNUserNotificationCenter delivers it (typically main, but as a raw DispatchQueue block). registerQuestionCategoryAndAddIfStillAwaiting wraps its write in Task { @MainActor }. Because a DispatchQueue.main block can run between two MainActor-task continuations, if a question notification is resolved concurrently with another being registered, the cleanup callback captures a snapshot before the new category was written — its setNotificationCategories call then lands last and silently clobbers the freshly minted category. The affected question notification would subsequently appear without its inline option buttons, falling through to applicationActivation.activateApplication() for every action.

Comment on lines +111 to +130
return result
}

#if DEBUG
func notificationDebugCallerTarget(params: [String: Any]) -> NotificationDebugTarget? {
guard let fallbackTabManager = activeTabManagerForCallerNotification() else { return nil }
let target = Self.callerNotificationTarget(
fallback: fallbackTabManager,
preferredWorkspaceId: v2UUID(params, "preferred_workspace_id"),
preferredSurfaceId: v2UUID(params, "preferred_surface_id"),
callerTTY: Self.normalizedTTYName(stringParam(params, "caller_tty")),
preferTTY: boolParam(params, "prefer_tty") ?? false
)
guard let target else { return nil }
return NotificationDebugTarget(
workspaceId: target.workspace.id,
surfaceId: target.surfaceId
)
}
#endif

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.

P1 Debug-only accessor and visibility widening in production Sources/

notificationDebugCallerTarget is a #if DEBUG-guarded method added to a production source file with no production caller. To make it compile, stringParam and boolParam are widened from private to internal — exactly the "visibility widened together with a wrapper accessor" pattern the no-test-debug-seam rule prohibits. The canonical fix is to isolate the debug target-resolution logic in a dedicated #if DEBUG extension file (as done with NotificationDebugEmitter.swift) and restore private on the helpers.

Rule Used: Do not add new test/debug seams (ForTesting-styl... (source)

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!

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

🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift`:
- Around line 415-499: The applyPendingReplyIfReady flow loses the reply when
sendTerminalInput fails because pendingReplyState is cleared before the RPC
completes. Preserve the ready reply through the send attempt and, on failure,
re-park it with bounded retry/backoff (separate from prerequisite waiting), or
invoke an existing user-visible failure path; ensure retries do not overwrite or
silently discard pending replies during bursts.
- Around line 153-163: The reply labels used by the
UNTextInputNotificationAction in MobilePushCoordinator must have concrete
translations. Update the corresponding mobile.push.reply.action,
mobile.push.reply.send, and mobile.push.reply.placeholder entries in
Localizable.xcstrings with actual English and Japanese values, preserving the
existing localization keys and locale structure.

In `@Resources/Localizable.xcstrings`:
- Around line 137-156: Update the localized values for
debug.notification.error.missingKind and debug.notification.error.missingEnabled
to use actionable product-facing wording, such as prompting users to choose a
notification type and whether notifications are enabled. Keep raw field names
out of these user-facing translations; retain them only in internal diagnostics
if needed.

In `@Sources/AppDelegate`+NotificationDeliverySeams.swift:
- Around line 84-105: The notificationDeliverySendTerminalReply method must
resolve the current delivery target before sending when
retargetsToLiveSurfaceOwner is true. Use
AppDelegate.shared.agentNotificationDeliveryTarget(claimedTabId: tabId,
surfaceId: surfaceId), retain the existing surface fallback otherwise, and
include the resolved tabId in the surface.send_text routing parameters so
retargeted replies reach the live workspace.

In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 982-1050: Add coordinator-owned storage for minted per-request
notification categories, and update
registerQuestionCategoryAndAddIfStillAwaiting and cancelNotification to mutate
that storage and derive the complete desired category set before each
setNotificationCategories call. Remove their dependence on independently fetched
getNotificationCategories snapshots for Feed-question categories, while
preserving unrelated categories and pruning entries for requests that are no
longer live so concurrent mint/cancel operations cannot overwrite one another.

In `@Sources/NotificationDebugEmitter.swift`:
- Around line 169-189: Update emitFeed to return false immediately when target
is nil, before calling feedEvent or starting ingestion. Only construct and emit
the WorkstreamEvent when a focused NotificationDebugTarget exists; preserve
optional surfaceId handling for valid workspace-scoped targets.
- Around line 175-177: Replace the Thread.detachNewThread usage in
NotificationDebugEmitter with an actor- or Task-owned asynchronous ingestion
path, avoiding ingestBlocking and native-thread creation for each feed action.
Reuse or add an async FeedCoordinator ingestion method that preserves event
processing while providing task lifecycle and cancellation ownership.

In `@Sources/TerminalNotificationCallerResolver.swift`:
- Around line 114-130: Move notificationDebugCallerTarget and its supporting
stringParam/boolParam parsing used only by DEBUG socket handling into a
dedicated DEBUG-only extension/file, keeping them excluded from production
builds. Restore the normal resolver parsers to private visibility and update the
debug socket code to use the isolated debug support without exposing production
APIs.
🪄 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: bafd68c2-253c-4fc6-b9be-1bb8d0638b42

📥 Commits

Reviewing files that changed from the base of the PR and between f536107 and 2c4cd9f.

📒 Files selected for processing (50)
  • CLI/cmux.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReply.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyDecision.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings
  • Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/PendingReplyStateTests.swift
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift
  • Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationTerminalReplying.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift
  • Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift
  • Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDismissalModelTests.swift
  • Resources/Localizable.xcstrings
  • Sources/AgentNotificationDelivery.swift
  • Sources/AppDelegate+NotificationDeliverySeams.swift
  • Sources/AppDelegate.swift
  • Sources/Cloud/PhonePushClient.swift
  • Sources/Cloud/PhonePushPayload.swift
  • Sources/Feed/FeedCoordinator.swift
  • Sources/IrohTransportDebugMenuButtons.swift
  • Sources/NotificationDebugEmitter.swift
  • Sources/NotificationDebugMenuButtons.swift
  • Sources/NotificationDebugTarget.swift
  • Sources/TerminalController+ControlNotificationContext.swift
  • Sources/TerminalController+DebugMethodNames.swift
  • Sources/TerminalController.swift
  • Sources/TerminalNotification.swift
  • Sources/TerminalNotificationCallerResolver.swift
  • Sources/TerminalNotificationLiveRetargetDelivery.swift
  • Sources/TerminalNotificationPolicy.swift
  • Sources/TerminalNotificationQueue.swift
  • Sources/TerminalNotificationStore.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/PhonePushPresenceGateTests.swift
  • ios/cmux/CmuxAppDelegate.swift
  • web/services/apns/payload.ts
  • web/services/apns/routePolicy.ts
  • web/tests/apns.test.ts

Comment on lines +137 to +156
"debug.notification.error.invalidKindOrTarget": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Unknown kind or no notification target" } },
"ja": { "stringUnit": { "state": "translated", "value": "不明な種類、または通知対象がありません" } }
}
},
"debug.notification.error.missingEnabled": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Missing enabled" } },
"ja": { "stringUnit": { "state": "translated", "value": "enabled がありません" } }
}
},
"debug.notification.error.missingKind": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Missing kind" } },
"ja": { "stringUnit": { "state": "translated", "value": "kind がありません" } }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use product-facing wording for debug errors.

These localized errors expose internal request fields (kind and enabled) directly to users. Replace them with actionable wording such as “Choose a notification type” and “Choose whether notifications are enabled”; retain the raw field names only in internal diagnostics.

As per coding guidelines, user-facing errors and command output must not expose implementation details.

🤖 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 `@Resources/Localizable.xcstrings` around lines 137 - 156, Update the localized
values for debug.notification.error.missingKind and
debug.notification.error.missingEnabled to use actionable product-facing
wording, such as prompting users to choose a notification type and whether
notifications are enabled. Keep raw field names out of these user-facing
translations; retain them only in internal diagnostics if needed.

Source: Coding guidelines

Comment thread Sources/AppDelegate+NotificationDeliverySeams.swift
Comment thread Sources/Feed/FeedCoordinator.swift
Comment thread Sources/NotificationDebugEmitter.swift Outdated
Comment on lines +175 to +177
Thread.detachNewThread {
_ = FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 300)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Replace the unowned blocking thread.

Each feed action creates a native thread and blocks in ingestBlocking; “Emit All” creates several concurrently, with no cancellation or lifecycle owner. Route this through an actor- or task-owned async ingestion path instead.

🤖 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 `@Sources/NotificationDebugEmitter.swift` around lines 175 - 177, Replace the
Thread.detachNewThread usage in NotificationDebugEmitter with an actor- or
Task-owned asynchronous ingestion path, avoiding ingestBlocking and
native-thread creation for each feed action. Reuse or add an async
FeedCoordinator ingestion method that preserves event processing while providing
task lifecycle and cancellation ownership.

Source: Coding guidelines

Comment on lines +114 to +130
#if DEBUG
func notificationDebugCallerTarget(params: [String: Any]) -> NotificationDebugTarget? {
guard let fallbackTabManager = activeTabManagerForCallerNotification() else { return nil }
let target = Self.callerNotificationTarget(
fallback: fallbackTabManager,
preferredWorkspaceId: v2UUID(params, "preferred_workspace_id"),
preferredSurfaceId: v2UUID(params, "preferred_surface_id"),
callerTTY: Self.normalizedTTYName(stringParam(params, "caller_tty")),
preferTTY: boolParam(params, "prefer_tty") ?? false
)
guard let target else { return nil }
return NotificationDebugTarget(
workspaceId: target.workspace.id,
surfaceId: target.surfaceId
)
}
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Keep DEBUG-only helpers out of the production resolver.

notificationDebugCallerTarget is added inline under Sources/, and stringParam/boolParam are widened solely so DEBUG socket handling in another file can call them. Move this debug-only target/parsing support into a dedicated debug extension/file and keep the resolver’s normal parsers private.

As per coding guidelines, “Production Swift source must not add test/debug-only seams” and “A genuinely unavoidable debug-only facility must be isolated in a dedicated debug file or folder.”

Also applies to: 299-305

🤖 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 `@Sources/TerminalNotificationCallerResolver.swift` around lines 114 - 130,
Move notificationDebugCallerTarget and its supporting stringParam/boolParam
parsing used only by DEBUG socket handling into a dedicated DEBUG-only
extension/file, keeping them excluded from production builds. Restore the normal
resolver parsers to private visibility and update the debug socket code to use
the isolated debug support without exposing production APIs.

Source: Coding guidelines

…lies, iOS reply re-park

- FeedCoordinator: all CMUXFeedQuestion.* category get->set round trips now
  append to one MainActor-serialized chain so a mint racing a mint or a
  cancel can no longer clobber the other's setNotificationCategories write
  (Greptile P1, CodeRabbit TOCTOU).
- Banner text replies resolve the live surface owner via
  agentNotificationDeliveryTarget before surface.send_text and route with
  the resolved workspace_id, failing closed when the surface is gone.
- iOS: a failed inline-reply RPC re-parks the reply (original createdAt, so
  the 120s TTL still bounds retries) instead of dropping it; a newer reply
  parked mid-send still wins.
- Debug: caller-target resolution moved behind the shared production seam
  resolvedCallerNotificationTarget; DEBUG-only param parsing lives in
  NotificationDebugTarget.swift and the resolver's helpers are private
  again. debug.notification.emit fails closed for feed kinds without a
  resolved target. Missing-param errors now say what to pass (EN+JA).

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

Copy link
Copy Markdown
Collaborator Author

Review findings addressed in b766aca:

  • Question-category race (Greptile P1 + CodeRabbit): every CMUXFeedQuestion.* get→set round trip now appends to one MainActor-serialized chain owned by FeedCoordinator, so concurrent mint/cancel round trips can no longer interleave and clobber each other's setNotificationCategories write.
  • Reply live-retargeting (CodeRabbit): notificationDeliverySendTerminalReply resolves agentNotificationDeliveryTarget when retargetsToLiveSurfaceOwner, routes surface.send_text with the resolved workspace_id, and fails closed when the surface is gone.
  • iOS reply lost on failed send (CodeRabbit): a failed RPC send re-parks the reply with its original createdAt (120s TTL still bounds retries); a newer reply parked mid-send wins.
  • Debug accessor in production resolver (Greptile P1 + CodeRabbit): the resolver now exposes one production seam, resolvedCallerNotificationTarget, used by both notification.create_for_caller and the DEBUG adapter, which moved to NotificationDebugTarget.swift with its own param parsing; stringParam/boolParam are private again.
  • emitFeed nil target (CodeRabbit): feed kinds fail closed without a resolved target.
  • Debug error wording (CodeRabbit): missing-param errors now say what to pass, EN+JA.

Skipped with reason:

  • Empty mobile.push.reply.* localizations: false positive — the verification script grepped for strings/unitStrings, but the catalog uses stringUnit; all three keys carry translated en+ja values.
  • Blocking threads in the debug emitter: intentional. feed.push waiters park a semaphore by design (that is the real hook-client contract the debug mode exercises); running that on a cooperative Task would starve the concurrency pool. Threads are bounded by the kind count, time-bounded by the 300s wait timeout, and DEBUG-only.

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

Caution

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

⚠️ Outside diff range comments (1)
Sources/Feed/FeedCoordinator.swift (1)

868-878: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Keep question and permission JSON parsing off the main actor.

inlineQuestionOptions(for:) calls WorkstreamQuestionPrompt.parse(...) from MainActor notification delivery, and permissionNotificationCategoryId(for:) also calls permission JSON helpers on this path. toolInputJSON is an arbitrary serialized agent payload without a size bound, and parse uses JSONSerialization.jsonObject(data:). Parse once before the MainActor flow into an immutable option/capability snapshot and reuse it through delivery and action registration.

🤖 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 `@Sources/Feed/FeedCoordinator.swift` around lines 868 - 878, Move parsing out
of the MainActor notification-delivery path: update the caller of
inlineQuestionOptions and permissionNotificationCategoryId to parse
toolInputJSON once before entering MainActor flow, build an immutable
options/capability snapshot, and pass that snapshot through delivery and action
registration. Refactor inlineQuestionOptions and
permissionNotificationCategoryId to consume the pre-parsed snapshot without
invoking WorkstreamQuestionPrompt.parse or permission JSON helpers.

Source: Coding guidelines

🤖 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 `@Resources/Localizable.xcstrings`:
- Around line 147-155: Update the localizations for
debug.notification.error.missingEnabled and debug.notification.error.missingKind
to include every supported catalog locale: ar, bs, da, de, en, es, fr, it, ja,
km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant. Preserve the
existing English and Japanese translations and provide corresponding
translations for the remaining locales.

In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 1044-1047: Change liveCategoryIds in the live
notification-category pruning flow to a Set of the mapped waiter request
identifiers, while preserving the existing CMUXFeedQuestion prefix filtering and
membership behavior in the categories filter.

In `@Sources/NotificationDebugTarget.swift`:
- Around line 17-21: Validate supplied preferred_workspace_id and
preferred_surface_id parameters before calling resolvedCallerNotificationTarget,
distinguishing omitted values from malformed, empty, invalid, or unresolved
identifiers. Return invalid_params for any supplied identifier that fails
validation, and only pass validated UUIDs into the existing fallback resolution
flow in NotificationDebugTarget.

In `@Sources/TerminalController.swift`:
- Around line 2238-2241: Update the notification debug handler around
NotificationDebugEmitter.shared.emit so a present force_banner key whose
notificationDebugBoolParam result is nil returns invalid_params. Preserve false
as the default only when force_banner is absent, and continue passing valid
boolean values to emit.

In `@Sources/TerminalNotificationCallerResolver.swift`:
- Around line 79-81: Update the unavailable guard in
TerminalNotificationCallerResolver to keep the "unavailable" error code while
replacing the implementation-specific message with a localized product-level
message such as “Notification target unavailable.” Add or reuse the
corresponding localization catalog entry rather than hardcoding user-facing
text.

---

Outside diff comments:
In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 868-878: Move parsing out of the MainActor notification-delivery
path: update the caller of inlineQuestionOptions and
permissionNotificationCategoryId to parse toolInputJSON once before entering
MainActor flow, build an immutable options/capability snapshot, and pass that
snapshot through delivery and action registration. Refactor
inlineQuestionOptions and permissionNotificationCategoryId to consume the
pre-parsed snapshot without invoking WorkstreamQuestionPrompt.parse or
permission JSON helpers.
🪄 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 Plus

Run ID: 96e073d9-9f12-4167-8361-9d8d95ebe490

📥 Commits

Reviewing files that changed from the base of the PR and between 6e9b204 and b766aca.

📒 Files selected for processing (8)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate+NotificationDeliverySeams.swift
  • Sources/Feed/FeedCoordinator.swift
  • Sources/NotificationDebugEmitter.swift
  • Sources/NotificationDebugTarget.swift
  • Sources/TerminalController.swift
  • Sources/TerminalNotificationCallerResolver.swift

Comment on lines +147 to +155
"en": { "stringUnit": { "state": "translated", "value": "Pass enabled=true or enabled=false to turn notification debug mode on or off." } },
"ja": { "stringUnit": { "state": "translated", "value": "enabled=true または enabled=false を指定して、通知デバッグモードのオン/オフを切り替えてください。" } }
}
},
"debug.notification.error.missingKind": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Pass kind=<notification kind> to choose which debug notification to emit." } },
"ja": { "stringUnit": { "state": "translated", "value": "kind=<通知の種類> を指定して、発行するデバッグ通知を選んでください。" } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

catalog="Resources/Localizable.xcstrings"

for key in \
  "debug.notification.error.missingEnabled" \
  "debug.notification.error.missingKind"
do
  echo "== $key =="
  jq -r --arg key "$key" \
    '.strings[$key].localizations // {} | keys[]' "$catalog" | sort
done

Repository: manaflow-ai/cmux

Length of output: 255


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

catalog="Resources/Localizable.xcstrings"
jq -r '
  .strings
  | to_entries[]
  | select(.key | startswith("debug.notification.error.missing"))
  | {key, locales: (.value.localizations // {} | keys | sort)}
' "$catalog"

echo "All locales in catalog:"
jq -r '[.strings | to_entries[].value.localizations // {} | keys[]] | unique | sort | .[]' "$catalog"

Repository: manaflow-ai/cmux

Length of output: 436


Add all supported locales for the notification debug strings.

debug.notification.error.missingEnabled and debug.notification.error.missingKind are present, but both only include en and ja instead of the catalog’s supported locales: ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant.

🤖 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 `@Resources/Localizable.xcstrings` around lines 147 - 155, Update the
localizations for debug.notification.error.missingEnabled and
debug.notification.error.missingKind to include every supported catalog locale:
ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk,
zh-Hans, and zh-Hant. Preserve the existing English and Japanese translations
and provide corresponding translations for the remaining locales.

Source: Coding guidelines

Comment on lines +1044 to +1047
let liveCategoryIds = self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
var categories = Set(current.filter { category in
!category.identifier.hasPrefix("CMUXFeedQuestion.")
|| liveCategoryIds.contains(category.identifier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)FeedCoordinator\.swift$' || true

echo "== outline relevant methods =="
ast-grep outline Sources/Feed/FeedCoordinator.swift --match liveWaiterRequestIds --view expanded || true
ast-grep outline Sources/Feed/FeedCoordinator.swift --view expanded | sed -n '1,220p' || true

echo "== relevant lines with context =="
sed -n '1020,1060p' Sources/Feed/FeedCoordinator.swift

echo "== liveWaiterRequestIds usages =="
rg -n "liveWaiterRequestIds|FeedCoordinator|categories.filter|current:" Sources/Feed Sources 2>/dev/null | head -200

Repository: manaflow-ai/cmux

Length of output: 25301


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect surrounding function =="
sed -n '960,1075p' Sources/Feed/FeedCoordinator.swift

echo "== deterministic membership scan simulation =="
python3 - <<'PY'
import random
random.seed(0)
categories = 1000
waiters = 1000
prefix = "CMUXFeedQuestion."
array_ids = list(f"{prefix}{i}" for i in range(waiters))
category_ids = [prefix + str(i) for i in range(categories)] + [f"Other.{i}" for i in range(categories)]

keep = [cid for cid in category_ids if not cid.startswith(prefix) or cid in array_ids]
print("category_count=", len(category_ids), "waiter_count=", len(array_ids))
print("comparisons_for_array_contains_per_keep=", category_count:=len(category_ids), "=>", category_count * len(array_ids))
print("matches=", len(keep))
PY

Repository: manaflow-ai/cmux

Length of output: 5023


Use a Set for live category IDs.

liveCategoryIds is an array, so contains(category.identifier) scans live waiter IDs for every dynamic category during notification-category pruning. Keep the mapped IDs in a Set before filtering.

Proposed fix
-            let liveCategoryIds = self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
+            let liveCategoryIds = Set(
+                self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let liveCategoryIds = self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
var categories = Set(current.filter { category in
!category.identifier.hasPrefix("CMUXFeedQuestion.")
|| liveCategoryIds.contains(category.identifier)
let liveCategoryIds = Set(
self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
)
var categories = Set(current.filter { category in
!category.identifier.hasPrefix("CMUXFeedQuestion.")
|| liveCategoryIds.contains(category.identifier)
🤖 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 `@Sources/Feed/FeedCoordinator.swift` around lines 1044 - 1047, Change
liveCategoryIds in the live notification-category pruning flow to a Set of the
mapped waiter request identifiers, while preserving the existing
CMUXFeedQuestion prefix filtering and membership behavior in the categories
filter.

Sources: Coding guidelines, Path instructions

Comment on lines +17 to +21
guard let target = resolvedCallerNotificationTarget(
preferredWorkspaceId: v2UUID(params, "preferred_workspace_id"),
preferredSurfaceId: v2UUID(params, "preferred_surface_id"),
callerTTY: notificationDebugStringParam(params, "caller_tty"),
preferTTY: notificationDebugBoolParam(params, "prefer_tty") ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 '\bv2UUID\s*\(' Sources
rg -n -C 10 'func resolvedCallerNotificationTarget\s*\(' Sources/TerminalNotificationCallerResolver.swift
rg -n -C 8 'notificationDebugCallerTarget|preferred_workspace_id|preferred_surface_id' Sources

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NotificationDebugTarget.swift =="
cat -n Sources/NotificationDebugTarget.swift | sed -n '1,140p'

echo
echo "== TerminalControllerV2ParamParsingSupport.swift =="
cat -n Sources/TerminalControllerV2ParamParsingSupport.swift | sed -n '1,180p'

echo
echo "== TerminalNotificationCallerResolver.swift outline/content =="
wc -l Sources/TerminalNotificationCallerResolver.swift
cat -n Sources/TerminalNotificationCallerResolver.swift | sed -n '1,260p'

echo
echo "== targeted debug/target parameter parsing patterns =="
rg -n -C 3 'v2RawString|v2MainSync|v2String|func v2HasNonNullParam|notificationDebugBoolParam|notificationDebugStringParam|resolvedCallerNotificationTarget' Sources/TerminalControllerV2ParamParsingSupport.swift Sources/NotificationDebugTarget.swift Sources/TerminalNotificationCallerResolver.swift

Repository: manaflow-ai/cmux

Length of output: 36067


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate debug notify call sites =="
rg -n -C 8 'notificationDebugCallerTarget|debug\.notification|notificationDebug' Sources

echo
echo "== read-only parser behavior for preferred_workspace_id/surface_id edge cases =="
python3 - <<'PY'
from pathlib import Path
p = Path("Sources/TerminalControllerV2ParamParsingSupport.swift")
text = p.read_text()
for i, line in enumerate(text.splitlines(), start=1):
    if i == 5 or i == 64 or i == 87:
        j = 0
        while line[j].isspace():
            j += 1
        if line[j:j+22] not in ("nonisolated func v2String", "nonisolated func v2RawString", "nonisolated func v2UUID"):
            continue
        depth = 1
        out = []
        idx = i - 1
        while depth > 0 and idx < len(text.splitlines()):
            out.append(f"{idx+1}: {text.splitlines()[idx]}")
            for ch in text.splitlines()[idx]:
                if ch == "{":
                    depth += 1
                elif ch == "}":
                    depth -= 1
            idx += 1
        print(f"\n== {line[j:j+22]} ==")
        print("\n".join(out))
PY

Repository: manaflow-ai/cmux

Length of output: 16927


Reject malformed target identifiers before fallback resolution.

v2UUID turns missing, empty, invalid UUID, and unresolved preferred_workspace_id/preferred_surface_id values into nil. At Sources/NotificationDebugTarget.swift:16, that is treated as omitted selection from resolvedCallerNotificationTarget, so debug notifications can target the fallback workspace. For supplied values, parse them first and return invalid_params before resolving.

🤖 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 `@Sources/NotificationDebugTarget.swift` around lines 17 - 21, Validate
supplied preferred_workspace_id and preferred_surface_id parameters before
calling resolvedCallerNotificationTarget, distinguishing omitted values from
malformed, empty, invalid, or unresolved identifiers. Return invalid_params for
any supplied identifier that fails validation, and only pass validated UUIDs
into the existing fallback resolution flow in NotificationDebugTarget.

Sources: Path instructions, Learnings

Comment on lines +2238 to +2241
let emitted = NotificationDebugEmitter.shared.emit(
kind: kind,
forceBanner: notificationDebugBoolParam(params, "force_banner") ?? false,
target: notificationDebugCallerTarget(params: params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject an invalid force_banner value.

Line 2240 converts every invalid but present force_banner value to false. Return invalid_params when the key is present and notificationDebugBoolParam returns nil. Keep false as the default only when the key is absent. Otherwise the RPC reports success while ignoring the caller value.

Based on learnings: a present invalid parameter must return invalid_params instead of silently falling back to a default.

🤖 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 `@Sources/TerminalController.swift` around lines 2238 - 2241, Update the
notification debug handler around NotificationDebugEmitter.shared.emit so a
present force_banner key whose notificationDebugBoolParam result is nil returns
invalid_params. Preserve false as the default only when force_banner is absent,
and continue passing valid boolean values to emit.

Source: Learnings

Comment on lines +79 to 81
guard activeTabManagerForCallerNotification() != nil else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a localized product-level unavailable message.

"TabManager not available" exposes an internal implementation type in the notification.create_for_caller API response. Return a localized product message such as “Notification target unavailable.” Keep "unavailable" as the machine-readable error classification.

As per coding guidelines, “User-facing errors, alerts, command output, API bodies, and recovery copy must not expose implementation details” and user-facing text must use localized APIs and matching catalogs.

🤖 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 `@Sources/TerminalNotificationCallerResolver.swift` around lines 79 - 81,
Update the unavailable guard in TerminalNotificationCallerResolver to keep the
"unavailable" error code while replacing the implementation-specific message
with a localized product-level message such as “Notification target
unavailable.” Add or reuse the corresponding localization catalog entry rather
than hardcoding user-facing text.

Source: Coding guidelines

…on settings

Reports authorizationStatus, alertStyle, and per-surface settings straight
from UNUserNotificationCenter so authorization problems on a dev build are
diagnosable over the socket.

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.

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 `@Sources/NotificationDebugTarget.swift`:
- Around line 46-53: Update notificationDebugStatus and the
debug.notification.status handling in v2LegacyMainActorResponse to avoid
DispatchSemaphore-based waiting on MainActor. Use an async continuation or move
the notification-settings lookup to a worker path, and keep the RPC response
connected to that non-blocking operation while preserving the existing payload
and timeout behavior.
🪄 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 Plus

Run ID: 043f9134-16ac-44e1-b08d-5c626b98d88f

📥 Commits

Reviewing files that changed from the base of the PR and between b766aca and ac3d411.

📒 Files selected for processing (2)
  • Sources/NotificationDebugTarget.swift
  • Sources/TerminalController.swift

Comment on lines +46 to +53
/// `debug.notification.status` — the system's actual notification settings
/// for this bundle id, so authorization/style problems are diagnosable from
/// the socket instead of screenshot archaeology. Blocks the socket worker
/// on the settings callback (bounded; DEBUG-only diagnostic).
nonisolated func notificationDebugStatus() -> [String: Any] {
let semaphore = DispatchSemaphore(value: 0)
var payload: [String: Any] = ["available": false]
UNUserNotificationCenter.current().getNotificationSettings { settings in

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '(^|/)NotificationDebugTarget\.swift$|(^|/)TerminalController\.swift$|\.github/review-bot-rules/.*blocking|reliability-single-source-of-truth\.md|algorithmic-complexity\.md' || true

echo "== NotificationDebugTarget outline and relevant lines =="
if [ -f Sources/NotificationDebugTarget.swift ]; then
  wc -l Sources/NotificationDebugTarget.swift
  ast-grep outline Sources/NotificationDebugTarget.swift --view expanded || true
  sed -n '1,140p' Sources/NotificationDebugTarget.swift | nl -ba
fi

echo "== TerminalController references =="
rg -n "notificationDebugStatus|v2LegacyMainActorResponse|debug\.notification\.status|socket worker|MainActor" Sources TerminalController.swift Sources/TerminalController.swift 2>/dev/null || true

echo "== TerminalController relevant section =="
if [ -f Sources/TerminalController.swift ]; then
  wc -l Sources/TerminalController.swift
  sed -n '2180,2245p' Sources/TerminalController.swift | nl -ba
fi

echo "== Search call sites =="
rg -n "notificationDebugStatus|getNotificationSettings|getNotificationSettings\(" Sources Tests 2>/dev/null || true

Repository: manaflow-ai/cmux

Length of output: 969


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NotificationDebugTarget relevant sections =="
sed -n '1,120p' Sources/NotificationDebugTarget.swift

echo
echo "== TerminalController relevant references and sections =="
sed -n '2180,2250p' Sources/TerminalController.swift
rg -n "notificationDebugStatus|v2LegacyMainActorResponse|debug\.notification\.status|dispatch_async_to_worker|dispatchAsyncToWorker|`@MainActor`|Actor|MainActor" Sources/TerminalController.swift Sources --glob '*.swift' 2>/dev/null || true

echo
echo "== fast behavioral facts from source text =="
python3 - <<'PY'
from pathlib import Path
nc = Path('Sources/NotificationDebugTarget.swift').read_text()
tc = Path('Sources/TerminalController.swift').read_text()
for phrase in ['DispatchSemaphore(value: 0)', 'DispatchSemaphore.wait(timeout:', 'notificationDebugStatus() -> [String: Any]', 'nonisolated func notificationDebugStatus()']:
    print('NC contains', phrase, phrase in nc)
for phrase in ['v2LegacyMainActorResponse', 'notificationDebugStatus()', 'debug.notification.status']:
    hits = []
    for i,l in enumerate(tc.splitlines(),1):
        if phrase in l: hits.append((i,l.strip()))
    print('TC matches for', phrase, hits)
PY

Repository: manaflow-ai/cmux

Length of output: 50373


Do not bridge the notification-settings callback onto MainActor with a semaphore.

Sources/NotificationDebugTarget.swift waits on DispatchSemaphore.wait(timeout:) for up to three seconds while invoked from Sources/TerminalController.swift’s v2LegacyMainActorResponse. This suspends the MainActor RPC path until the callback fires or the timeout expires. Use an async continuation or a worker path for debug.notification.status; keep the RPC response tied to that non-blocking operation.

🤖 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 `@Sources/NotificationDebugTarget.swift` around lines 46 - 53, Update
notificationDebugStatus and the debug.notification.status handling in
v2LegacyMainActorResponse to avoid DispatchSemaphore-based waiting on MainActor.
Use an async continuation or move the notification-settings lookup to a worker
path, and keep the RPC response connected to that non-blocking operation while
preserving the existing payload and timeout behavior.

Source: Coding guidelines

# Conflicts:
#	Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift
#	Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings
#	Sources/Cloud/PhonePushClient.swift
#	Sources/TerminalController+ControlNotificationContext.swift
#	Sources/TerminalController.swift
#	cmux.xcodeproj/project.pbxproj
#	web/services/apns/routePolicy.ts
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

Caution

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

⚠️ Outside diff range comments (1)
Sources/Cloud/PhonePushClient.swift (1)

350-356: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Normalize empty dismissal replyShape values.

PhonePushPayload.init(...) accepts an empty String for replyShape, so .dismiss envelopes currently can carry replyShape: "". Update the Swift envelope constructor to enforce none/text or omit the field for dismissals, and add route coverage for missing and unknown values.

🤖 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 `@Sources/Cloud/PhonePushClient.swift` around lines 350 - 356, The dismiss
envelope construction in Sources/Cloud/PhonePushClient.swift:350-356 must not
pass an empty replyShape; normalize it to the supported none/text values or omit
the field. Update PhonePushPayload.swift:8-9 and 36-50 so initialization and
routing accept missing values safely while rejecting or normalizing unknown
values, and add coverage for both missing and unknown replyShape inputs.
♻️ Duplicate comments (1)
Sources/Feed/FeedCoordinator.swift (1)

1288-1292: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use a Set for liveCategoryIds.

liveWaiterRequestIds() returns a Set<String>, but .map produces an Array. contains then performs a linear scan for every dynamic category in current, making the prune O(categories × liveWaiters). Keep the mapped identifiers in a Set.

♻️ Proposed fix
-            let liveCategoryIds = self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
+            let liveCategoryIds = Set(
+                self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" }
+            )

As per coding guidelines: "Avoid repeated full scans, sorting, filtering, or per-item nested scans over scalable collections in production code."

🤖 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 `@Sources/Feed/FeedCoordinator.swift` around lines 1288 - 1292, Change
liveCategoryIds in the category-pruning flow to a Set by preserving the mapped
identifier values in set form, so contains performs constant-time membership
checks while filtering current. Keep the existing identifier prefix and category
filtering behavior unchanged.

Sources: Coding guidelines, Path instructions

🤖 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
`@Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift`:
- Around line 28-45: Extend WorkstreamQuestionPrompt parsing tests with coverage
for nil and invalid JSON returning an empty result, and for nested questions
using the snake_case multi_select alias. In the multi-question test, verify
fallback identifiers are indexed as q0 and q1 and that the second question
enables multiSelect.

In
`@Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift`:
- Around line 48-52: Replace the independent fire-and-forget Task around
category installation with a single serialized notification-category owner that
exclusively performs read-modify-write mutations for both configuration and
dynamic updates. Update the installation API to enqueue its mutation through
that owner and expose completion so callers can await or otherwise observe
completion, and route FeedCoordinator’s dynamic CMuxFeedQuestion.* updates
through the same owner.

In
`@Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift`:
- Around line 136-137: Replace the unreliable Task.yield() synchronization in
the test with a real completion signal from FakeNotificationCenter for
notification category installation. Expose and await a continuation or
expectation that is fulfilled after configureUserNotifications completes its
category read/write, then assert center.categories only after that signal, using
a deadline-bounded wait if an expectation is used.

In `@Resources/Localizable.xcstrings`:
- Around line 4-36: Update the new entries under cli.help.notify.reply and
debug.menu.notification.* to include localizations for every supported catalog
locale: ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr,
uk, zh-Hans, and zh-Hant. Preserve the existing English and Japanese
translations, and add translated stringUnit values for each remaining locale.

In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 1390-1397: Update cancelNotification around the categoryId cleanup
so enqueueQuestionCategoryUpdate is invoked only when the corresponding
CMUXFeedQuestion.<requestId> category was actually minted. Skip the
notificationCategories read and setNotificationCategories round trip for
permission or exit-plan requests without a per-request category.

In `@Sources/TerminalNotificationCallerResolver.swift`:
- Around line 83-86: Update the parameter handling in
TerminalNotificationCallerResolver to distinguish omitted preferred_workspace_id
and preferred_surface_id values from supplied malformed identifiers. Reuse the
strict identifier parser from NotificationDebugTarget, and return invalid_params
when either supplied value fails validation; preserve fallback target selection
only when the identifiers are omitted.

---

Outside diff comments:
In `@Sources/Cloud/PhonePushClient.swift`:
- Around line 350-356: The dismiss envelope construction in
Sources/Cloud/PhonePushClient.swift:350-356 must not pass an empty replyShape;
normalize it to the supported none/text values or omit the field. Update
PhonePushPayload.swift:8-9 and 36-50 so initialization and routing accept
missing values safely while rejecting or normalizing unknown values, and add
coverage for both missing and unknown replyShape inputs.

---

Duplicate comments:
In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 1288-1292: Change liveCategoryIds in the category-pruning flow to
a Set by preserving the mapped identifier values in set form, so contains
performs constant-time membership checks while filtering current. Keep the
existing identifier prefix and category filtering behavior unchanged.
🪄 Autofix

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 Plus

Run ID: bb440f3c-ecec-49d8-90ef-5560b2c2c453

📥 Commits

Reviewing files that changed from the base of the PR and between 6bb1d7b and 5f9c7f8.

📒 Files selected for processing (50)
  • CLI/cmux.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReply.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyDecision.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings
  • Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/PendingReplyStateTests.swift
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift
  • Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift
  • Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationTerminalReplying.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift
  • Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift
  • Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift
  • Resources/Localizable.xcstrings
  • Sources/AgentNotificationDelivery.swift
  • Sources/AppDelegate+NotificationDeliverySeams.swift
  • Sources/AppDelegate.swift
  • Sources/Cloud/PhonePushClient.swift
  • Sources/Cloud/PhonePushPayload.swift
  • Sources/Cloud/PhonePushRequestEnvelope.swift
  • Sources/Feed/FeedCoordinator.swift
  • Sources/IrohTransportDebugMenuButtons.swift
  • Sources/NotificationDebugEmitter.swift
  • Sources/NotificationDebugMenuButtons.swift
  • Sources/NotificationDebugTarget.swift
  • Sources/TerminalController+ControlNotificationContext.swift
  • Sources/TerminalController+DebugMethodNames.swift
  • Sources/TerminalController.swift
  • Sources/TerminalNotification.swift
  • Sources/TerminalNotificationCallerResolver.swift
  • Sources/TerminalNotificationLiveRetargetDelivery.swift
  • Sources/TerminalNotificationPolicy.swift
  • Sources/TerminalNotificationQueue.swift
  • Sources/TerminalNotificationStore.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/PhonePushPresenceGateTests.swift
  • ios/cmux/CmuxAppDelegate.swift
  • web/services/apns/payload.ts
  • web/services/apns/routePolicy.ts
  • web/tests/apns.test.ts

Comment on lines +28 to +45
@Test("parses flat questions and defaults multi-select to false")
func parsesFlatQuestion() throws {
let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #"""
{
"prompt": "Choose",
"options": ["Alpha", "Beta"]
}
"""#)

let question = try #require(parsed.first)
#expect(question.id == "q0")
#expect(question.prompt == "Choose")
#expect(!question.multiSelect)
#expect(question.options == [
.init(id: "opt0", label: "Alpha"),
.init(id: "opt1", label: "Beta"),
])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the empty-result and alias paths.

FeedCoordinator.inlineQuestionOptions treats an empty parse result as "no inline options" and falls back to the shared CMUXFeedQuestion category. That fail-closed path is untested. The multi_select snake_case alias and the multi-question fallback id (q1) are also untested.

🧪 Proposed additional tests
`@Test`("returns no prompts for absent or invalid input")
func parsesInvalidInput() {
    `#expect`(WorkstreamQuestionPrompt.parse(toolInputJSON: nil).isEmpty)
    `#expect`(WorkstreamQuestionPrompt.parse(toolInputJSON: "not json").isEmpty)
}

`@Test`("honors snake_case multi_select and indexed fallback ids")
func parsesSnakeCaseMultiSelect() throws {
    let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #"""
    {"questions": [{"question": "A"}, {"question": "B", "multi_select": true}]}
    """#)

    `#expect`(parsed.count == 2)
    `#expect`(parsed[0].id == "q0")
    `#expect`(parsed[1].id == "q1")
    `#expect`(parsed[1].multiSelect)
}
🤖 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
`@Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift`
around lines 28 - 45, Extend WorkstreamQuestionPrompt parsing tests with
coverage for nil and invalid JSON returning an empty result, and for nested
questions using the snake_case multi_select alias. In the multi-question test,
verify fallback identifiers are indexed as q0 and q1 and that the second
question enables multiSelect.

Comment on lines +48 to +52
Task { @MainActor [weak self] in
guard let self else { return }
let current = await center.currentNotificationCategories()
center.setNotificationCategories(current.union(notificationCategories()))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize all notification-category mutations through one owner.

Line 48 starts an independent async read-modify-write operation. FeedCoordinator performs another async read-modify-write operation for dynamic CMUXFeedQuestion.* categories. If both reads complete before either write, the last write can remove terminal reply categories or dynamic question categories.

Use one serialized category-update owner for configuration and dynamic updates. Make category installation expose a completion signal through that owner.

As per coding guidelines, use one explicit owner for state and do not create fire-and-forget Task work with meaningful lifecycle.

🤖 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
`@Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift`
around lines 48 - 52, Replace the independent fire-and-forget Task around
category installation with a single serialized notification-category owner that
exclusively performs read-modify-write mutations for both configuration and
dynamic updates. Update the installation API to enqueue its mutation through
that owner and expose completion so callers can await or otherwise observe
completion, and route FeedCoordinator’s dynamic CMuxFeedQuestion.* updates
through the same owner.

Source: Coding guidelines

Comment on lines +4 to +36
"cli.help.notify.reply": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "--reply Allow a free-text inline reply" } },
"ja": { "stringUnit": { "state": "translated", "value": "--reply 自由入力のインライン返信を許可" } }
}
},
"debug.menu.notification.all": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Emit All" } },
"ja": { "stringUnit": { "state": "translated", "value": "すべて送信" } }
}
},
"debug.menu.notification.cli": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "CLI" } },
"ja": { "stringUnit": { "state": "translated", "value": "CLI" } }
}
},
"debug.menu.notification.cliReply": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "CLI Reply" } },
"ja": { "stringUnit": { "state": "translated", "value": "CLI 返信" } }
}
},
"debug.menu.notification.emit": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Emit Notification" } },
"ja": { "stringUnit": { "state": "translated", "value": "通知を送信" } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

catalog="Resources/Localizable.xcstrings"

keys="$(jq -r '
  .strings
  | to_entries[]
  | select(
      .key == "cli.help.notify.reply"
      or (.key | startswith("debug.menu.notification."))
      or (.key | startswith("debug.notification."))
      or (.key | startswith("feed.notification."))
      or (.key | startswith("terminal.notification."))
    )
  | .key
' "$catalog" | sort -u)"

catalog_locales="$(jq -r '
  [.strings[] | (.localizations // {}) | keys[]]
  | unique
  | sort[]
' "$catalog")"

while IFS= read -r key; do
  key_locales="$(jq -r --arg key "$key" '
    .strings[$key].localizations // {}
    | keys[]
  ' "$catalog" | sort)"

  missing="$(comm -23 \
    <(printf '%s\n' "$catalog_locales") \
    <(printf '%s\n' "$key_locales"))"

  if [[ -n "$missing" ]]; then
    printf '%s is missing: %s\n' "$key" "$(tr '\n' ' ' <<< "$missing")"
  fi
done <<< "$keys"

Repository: manaflow-ai/cmux

Length of output: 198


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

catalog="Resources/Localizable.xcstrings"

echo "== file exists and first relevant keys =="
python3 - <<'PY'
import json
from pathlib import Path

p = Path("Resources/Localizable.xcstrings")
data = json.loads(p.read_text())
targets = []
for key in data.get("strings", {}):
    if key == "cli.help.notify.reply" or \
       key.startswith("debug.menu.notification.") or \
       key.startswith("debug.notification.") or \
       key.startswith("feed.notification.") or \
       key.startswith("terminal.notification."):
        locs = sorted((data["strings"][key]["localizations"] or {}).keys())
        targets.append((key, locs))
catalog_loc_set = set()
for locs in ((data["strings"].get(k, {}) or {}).get("localizations") or {} for k in data.get("strings", {})):
    catalog_loc_set.update(locs)
for locs in (data.get("metadata", {}) or {}).get("localizations") or []:
    catalog_loc_set.update(locs)
print("catalog_locales", sorted(catalog_loc_set))
for key, locs in targets:
    print(key, locs)
    missing = sorted(set(catalog_loc_set) - set(locs))
    if missing:
        print("  MISSING", missing)
PY

echo
echo "== changed files/diff target range summary =="
git diff -- Resources/Localizable.xcstrings | head -n 220

Repository: manaflow-ai/cmux

Length of output: 9221


Add localization entries for every supported catalog locale.

Resources/Localizable.xcstrings supports ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant, but the new strings under the affected ranges define translations only for en and ja. This will show English fallback text in those strings for every other supported locale. Add localizations entries for all supported locales.

🤖 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 `@Resources/Localizable.xcstrings` around lines 4 - 36, Update the new entries
under cli.help.notify.reply and debug.menu.notification.* to include
localizations for every supported catalog locale: ar, bs, da, de, en, es, fr,
it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant. Preserve
the existing English and Japanese translations, and add translated stringUnit
values for each remaining locale.

Sources: Coding guidelines, Path instructions, Learnings

Comment thread Sources/Feed/FeedCoordinator.swift Outdated
Comment on lines 83 to 86
let preferredWorkspaceId = v2UUID(params, "preferred_workspace_id")
let preferredSurfaceId = v2UUID(params, "preferred_surface_id")
let callerTTY = Self.normalizedTTYName(stringParam(params, "caller_tty"))
let callerTTY = stringParam(params, "caller_tty")
let preferTTY = boolParam(params, "prefer_tty") ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject malformed preferred target identifiers.

Line 83 and Line 84 convert malformed supplied identifiers to nil. The resolver then selects a fallback target at Line 94. A notification with reply_shape: "text" can therefore create a reply action for the wrong terminal.

Distinguish omitted identifiers from supplied invalid identifiers. Return invalid_params for invalid values. Use the same strict parser in Sources/NotificationDebugTarget.swift.

🤖 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 `@Sources/TerminalNotificationCallerResolver.swift` around lines 83 - 86,
Update the parameter handling in TerminalNotificationCallerResolver to
distinguish omitted preferred_workspace_id and preferred_surface_id values from
supplied malformed identifiers. Reuse the strict identifier parser from
NotificationDebugTarget, and return invalid_params when either supplied value
fails validation; preserve fallback target selection only when the identifiers
are omitted.

Source: Path instructions

azooz2003-bit and others added 3 commits August 5, 2026 21:03
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DEBUG-only button in Settings > Push Alerts schedules a LOCAL notification
carrying the same cmux.terminal.reply category and cmux userInfo schema as a
Mac-forwarded push, addressed at the selected workspace/terminal. The response
path cannot tell local from remote, so the inline Reply UX, parking, and the
terminal.input RPC back to the Mac are verifiable on a device without APNs —
dev web deployments have no push service configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev iPhones register APNs tokens with the staging deployment (the device
rig default), so a Debug Mac posting pushes to its tag-local localhost
port can never deliver: that origin has no token registry, and every
forward died queued. Route /api/notifications/* through a push-specific
base that mirrors irohBrokerBaseURL: explicit CMUX_PUSH_API_BASE_URL or
VM-API overrides win, Debug defaults to staging, Release keeps the
production VM-API origin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 4 commits August 6, 2026 20:41
The tag rig bakes a localhost CMUX_VM_API_BASE_URL into every Debug
bundle's LSEnvironment, so deferring to that knob re-broke the push lane
on every fresh build. Only an explicit CMUX_PUSH_API_BASE_URL (env or
~/.cmux-dev.env) overrides the Debug staging default now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The queue only logged terminal outcomes, so a failing rig read as opaque
invalid_response/retry_exhausted lines with no way to tell a redirect from
a decode mismatch from a transport error. Log host, HTTP status, byte
count, and classification per attempt (never content).

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

- Question-category serialization now rides main's UserNotificationCenterServing
  seam: new bounded notificationCategories() read on the service, register/
  cancel merge inside the coordinator-owned serialized chain.
- Empty exit-plan Revise… no longer approves the plan: it opens the app at the
  card, matching the empty question-Other… fallback (cursor High), with a
  regression test.
- A failed iOS inline-reply send arms a bounded, cancellable 5s retry through
  an injected sleep, so a transient RPC failure with unchanged topology cannot
  strand the re-parked reply until TTL (cursor High).
- Release iOS Settings shows only the Allow Push Alerts toggle; the delivery
  diagnostics, Mac forwarding controls, and test actions are DEBUG-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit and others added 2 commits August 7, 2026 21:38
Main added direct test call sites for the control notification entrypoints
that predate the reply-shape parameter; a nil default keeps every legacy
caller source-compatible while the socket dispatcher still passes the wire
value through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…parked reply while channel is down

- The launch/category (re)install now merges live CMUXFeedQuestion.*
  categories through the new bounded read instead of replacing the whole
  set, so a re-configure can no longer strip a live question banner's
  option buttons. Regression test included.
- A reply parked because the RPC channel is unavailable arms the same
  bounded retry ladder as a failed send, so a channel that recovers
  without emitting a store event cannot strand the reply until TTL.

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

Copy link
Copy Markdown
Collaborator Author

Merging per direct directive. Gate math: GitHub required checks green (Greptile, CodeRabbit, cubic, Socket); merge-gate web-typecheck/react-apps-check/web-db-migrations/remote-daemon-tests/workflow-guard-tests all pass; the remaining app-host unit-test shard reds fail identically on a pure-main baseline run (https://github.com/manaflow-ai/cmux/actions/runs/31287521157 — same CLINotifyProcessIntegration*/AgentSessionAutoResume* suites, catalogued in #8565), and main additionally fails swift-package-tests and shard 3, which this branch passes. All cursor/CodeRabbit/Greptile High+Medium findings were fixed with regression tests or answered inline; iOS compile proven via simulator build; 94 CmuxNotifications tests, 59 APNs tests, and web typecheck green on the final head.

@azooz2003-bit
azooz2003-bit merged commit 24cb551 into main Aug 9, 2026
7 checks passed

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ea468f9. Configure here.

request: request,
requestId: requestId,
effects: effects
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unregistered question category fallback

Medium Severity

When reading or writing notification categories fails, the AskUserQuestion banner is still posted with its dynamic CMUXFeedQuestion.{requestId} category. That category was never registered, so the banner has no option buttons and also never falls back to the static open-only CMUXFeedQuestion category the comment describes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ea468f9. Configure here.

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