Skip to content

ssh-tmux: deliver OSC 777/9 notifications from mirrored panes (#833) - #10048

Open
alloevil wants to merge 2 commits into
manaflow-ai:mainfrom
alloevil:fix-833-mirror-notifications
Open

ssh-tmux: deliver OSC 777/9 notifications from mirrored panes (#833)#10048
alloevil wants to merge 2 commits into
manaflow-ai:mainfrom
alloevil:fix-833-mirror-notifications

Conversation

@alloevil

@alloevil alloevil commented Aug 12, 2026

Copy link
Copy Markdown

Fixes #833 for ssh-tmux mirrors. Follows up on the mechanism documented in #8382's dogfood comment and the design sketch in #833's discussion.

Problem

In a pure ssh-tmux mirror every remote shell runs inside tmux, so shell integration only ever pulls CMUX_* values — nothing publishes. The tmux session environment goes permanently stale, and OSC 777/9 notification escapes from remote agents reach a mirror surface with no local process TTY to attribute them by. The failure is silent: agent hooks report "sent", nothing arrives, the user concludes the agent is still working.

Changes

Both in the mirror layer — no remote daemon, no user tmux config (allow-passthrough not required):

1. Session-scoped env push on attach + every reconnect. A new pushMirrorSessionEnvironment() runs at the post-attach drain point (pendingPostAttachAction consumption), covering first connect and every reconnect. Two deliberate deviations from the local publisher, both verified empirically against tmux 3.3a:

  • Session scope (set-environment -t), not -g: the shell-integration refresh path runs a session-scoped show-environment, which does not surface global values. A -g push (as originally sketched in the issue) would be invisible to the pull path.
  • CMUX_SOCKET_PATH is NOT pushed: the ssh-tmux transport has no relay, so a local Mac socket path would be dead on the remote. Pushed instead: CMUX_REMOTE_TMUX_MIRROR=1 marker + CMUX_WORKSPACE_ID/CMUX_TAB_ID identity.

2. Pane-scoped OSC 777/9 interception. RemoteTmuxNotificationOSCFilter — a chunk-split-safe FSM modeled on RemoteTmuxScreenTitleFilter, wired beside it in routeOutput — strips OSC 777;notify;<title>;<body> and OSC 9;<body> (BEL/ST) from mirrored pane output and delivers through the shared GhosttyDesktopNotificationIngress, attributed pane→surface→workspace. Pane identity is ground truth in mirror mode; launch-time env vars never were (this also sidesteps the cross-workspace misrouting family in the issue thread).

  • Reseed replay strips but suppresses delivery: a reconnect's full-history reseed cannot re-fire old notifications.
  • Unfinished-candidate buffer is capped at 4KB; overflow passes through verbatim, so a hostile/corrupt stream can never pin memory or swallow output. Any non-notification OSC passes through byte-identical.

Tests

  • RemoteTmuxNotificationOSCFilterTests (18 cases): BEL/ST termination, chunk splits at every interesting boundary (post-ESC, mid-payload, pre-terminator), non-notify OSC 777 subcommands pass through, >4KB overflow passthrough, interleaved plain output integrity, UTF-8 bodies.
  • RemoteTmuxMirrorEnvironmentPushTests (6 cases): exact wire format of the set-environment command lines (pure builder), CR/LF-hostile value rejection, marker inclusion.

Caveats

  • Developed on Linux against main; FSM logic was cross-validated with a line-equivalent port against all test scenarios, and every API call was checked against the callee's source, but I could not compile or run the app locally. Happy to iterate on anything the macOS build surfaces.
  • xcodeproj wiring for the 3 new files is included (lint-pbxproj-test-wiring passes).
  • Notification → workspace targeting for processes that keep stale launch-time env (cmux notify from inside long-running remote processes) is out of scope here; the OSC path covers agent hook notifications without any env dependency.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Delivers OSC 777/9 desktop notifications from ssh‑tmux mirrored panes and keeps the remote tmux session identity fresh. Previously notifications were parsed but dropped and CMUX_* in the tmux session went stale after relaunch; now pane output is intercepted and delivered locally, and session‑scoped env is pushed on attach and reconnect.

Key changes

  • Push session‑scoped env to the remote tmux session after the attach drain and on every reconnect: CMUX_REMOTE_TMUX_MIRROR=1, CMUX_WORKSPACE_ID, and CMUX_TAB_ID; excludes CMUX_SOCKET_PATH. Builder sorts/quotes pairs, drops unsafe values, and targets by stable session id or quoted name. Seed the connection’s mirror identity from the workspace at creation so the first push has correct IDs.
  • Add RemoteTmuxNotificationOSCFilter beside the title filter: intercepts OSC ] 777;notify;… and OSC ] 9;… (BEL/ST), strips them, and delivers via GhosttyDesktopNotificationIngress attributed to the pane’s surface and workspace. Reseed strips without re‑delivering; unfinished buffer capped at 4KB; all other OSC and overflows pass through byte‑identical.

Review and tests

  • No user tmux config or remote daemon; plain cmux ssh workspaces are unchanged.
  • Review post‑attach/reconnect pushes in RemoteTmuxControlConnection (including immediate push via setMirrorEnvironment) and output/seed routing in RemoteTmuxSessionMirror.
  • Tests: RemoteTmuxMirrorEnvironmentPushTests (command format and safety) and RemoteTmuxNotificationOSCFilterTests (terminators, chunk splits, overflow, pass‑through, UTF‑8).

Written for commit 9fa5bed. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Remote tmux sessions now receive workspace identity information automatically, including on reconnects.
    • Mirrored sessions now synchronize workspace environment details with the remote session.
    • Desktop notifications from mirrored remote panes are detected and displayed with the correct workspace and surface context.
    • Notification processing supports common terminal formats, split output, and UTF-8 content.
  • Bug Fixes

    • Prevented malformed or oversized notification data from disrupting terminal output.
    • Historical or replayed output no longer generates duplicate notifications.

…ow-ai#833)

In a pure ssh-tmux mirror every remote shell runs inside tmux, so the
shell integration only ever pulls CMUX_* values and nothing publishes;
the tmux session environment goes permanently stale after an app
relaunch, and notification escapes emitted by remote agents are parsed
by a mirror surface that has no local process TTY to attribute them by.
The failure is silent: agent hooks report success and nothing arrives.

Two changes, both in the mirror layer (no remote daemon, no user tmux
config required):

- Push mirror identity into the remote tmux SESSION environment
  (set-environment -t) at the post-attach drain point, covering first
  connect and every reconnect. Session scope is deliberate: the
  shell-integration refresh path runs a session-scoped
  show-environment, which does not surface -g values.
  CMUX_SOCKET_PATH is deliberately NOT pushed - the ssh-tmux transport
  has no relay, so a local socket path would be dead on the remote.

- Intercept OSC 777;notify and OSC 9 sequences in the mirrored pane
  output stream (RemoteTmuxNotificationOSCFilter, a chunk-split-safe
  FSM with a 4KB unfinished-sequence ceiling), strip them, and deliver
  through the shared GhosttyDesktopNotificationIngress attributed to
  the pane's surface and workspace. Reseed replay strips but suppresses
  delivery so a reconnect cannot re-fire old notifications. Pane
  identity is ground truth in mirror mode; launch-time env vars never
  were.

Fixes manaflow-ai#833 for ssh-tmux mirrors; plain `cmux ssh` workspaces are
unaffected.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9aba8135-ca3b-48a3-81cb-e0d80ce9b037

📥 Commits

Reviewing files that changed from the base of the PR and between 2dc2a74 and 9fa5bed.

📒 Files selected for processing (1)
  • cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift

📝 Walkthrough

Walkthrough

The change adds session-scoped environment propagation for remote tmux mirrors and filters OSC 777/9 notifications from remote tmux output. Notifications are routed per pane to the mirrored workspace and surface. Tests cover command generation, reconnects, parsing, chunk boundaries, passthrough, and buffering limits.

Changes

Mirror environment propagation

Layer / File(s) Summary
Environment state and mirror identity
Sources/RemoteTmuxControlConnection.swift, Sources/RemoteTmuxController.swift
The connection stores mirror environment values. Mirror creation publishes workspace and legacy tab identity variables.
Session-scoped environment push
Sources/RemoteTmuxControlConnection+Commands.swift, Sources/RemoteTmuxControlConnection+CommandResults.swift, cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift, cmux.xcodeproj/project.pbxproj
The connection builds sorted, validated, shell-quoted set-environment commands for the mirrored session. It pushes values during attach, reseeding, client-size application, and reconnect. Tests cover targeting, quoting, filtering, identity seeding, and socket-path exclusion.

Remote tmux notifications

Layer / File(s) Summary
Stateful OSC notification filter
Sources/RemoteTmuxNotificationOSCFilter.swift, cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift, cmux.xcodeproj/project.pbxproj
The new filter parses OSC 777 and OSC 9 across byte chunks, supports BEL and ST terminators, decodes UTF-8 payloads, preserves unrelated sequences, and enforces a bounded buffer.
Pane output notification routing
Sources/RemoteTmuxSessionMirror+OutputRouting.swift, Sources/RemoteTmuxSessionMirror.swift
Per-pane filters process live and seed output. Historical output suppresses notification callbacks. Live notifications are delivered with workspace and optional surface context. Filters reset when the connection leaves the connected state.

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

Mergeability Score: ⚪ Minimal · up to 9fa5b

This change adds mirrored-pane notifications and refreshes remote tmux session identity; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

  • manaflow-ai/cmux#9786: Modifies related remote-tmux projected-pane identity handling, but uses different functionality and code paths.

Sequence Diagram(s)

sequenceDiagram
  participant Remote tmux
  participant RemoteTmuxSessionMirror
  participant RemoteTmuxNotificationOSCFilter
  participant Notification ingress
  Remote tmux->>RemoteTmuxSessionMirror: Send pane output
  RemoteTmuxSessionMirror->>RemoteTmuxNotificationOSCFilter: Filter output bytes
  RemoteTmuxNotificationOSCFilter->>RemoteTmuxSessionMirror: Return filtered bytes and decoded notification
  RemoteTmuxSessionMirror->>Notification ingress: Deliver live notification with workspace and surface
Loading

Important

Pre-merge checks failed

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

❌ Failed checks (3 errors)

Check name Status Explanation Resolution
Cmux Swift Package Boundaries ❌ Error The diff adds a 201-line Foundation-only, stateful OSC parser in app Sources/ with dedicated unit tests; the analogous RemoteTmuxScreenTitleFilter already lives in CmuxRemoteSession. Move the parser into the existing CmuxRemoteSession target and expose public RemoteTmuxNotificationOSCFilter; keep pane routing and Ghostty ingress in the app target.
Cmux Architecture Rethink ❌ Error The diff adds a second process-wide GhosttyDesktopNotificationIngress beside GhosttyApp.desktopNotificationIngress, creating a parallel notification owner and side channel. Inject the existing GhosttyApp.desktopNotificationIngress into mirrors from the composition root, remove paneNotificationIngress, and test the single-ingress invariant.
Cmux No Ambient Global State ❌ Error Sources/RemoteTmuxSessionMirror+OutputRouting.swift:10 adds static paneNotificationIngress, a runtime GhosttyDesktopNotificationIngress singleton shared by every mirror. Own the ingress on RemoteTmuxSessionMirror and pass it through the initializer from RemoteTmuxController, or inject an app-scoped service at that construction seam.
✅ Passed checks (22 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation addresses issue #833 by forwarding OSC notifications locally, supporting tmux session identity, and requiring no remote daemon or user configuration.
Out of Scope Changes check ✅ Passed The environment propagation, notification filtering, routing, tests, and Xcode wiring directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Cmux Swift Actor Isolation ✅ Passed Changed state and UI access remain on existing @MainActor types; observer callbacks emit on MainActor. The parser is a file-level value type, and the target uses Swift 5 without default MainActor i...
Cmux Swift Blocking Runtime ✅ Passed The PR adds no semaphores, waits, sleeps, delayed dispatch, polling, main-queue sync, or locks; production additions use stateful parsing and event-driven callbacks only.
Cmux Browser Automation Off-Main ✅ Passed The PR commit changes only remote tmux mirror, notification, project, and tests; it does not modify browser socket commands, worker routing, WebKit/AppKit hops, or policy tests.
Cmux Expensive Synchronous Load ✅ Passed The PR diff adds only bounded OSC byte filtering and tmux command construction; it adds no RestorableAgentSessionIndex.load, agent-history file parse, directory scan, or JSON/JSONL load.
Cmux Cache Substitution Correctness ✅ Passed The PR adds mirror environment state and notification stream filters; routeSeed still consumes the authoritative seed and uses a fresh snapshot filter. No cached substitution enters persistence, hi...
Cmux No Hacky Sleeps ✅ Passed The PR changes only Swift sources/tests and Xcode project registration; no covered TypeScript, JavaScript, shell, or build/runtime fixed-delay code was introduced.
Cmux Algorithmic Complexity ✅ Passed The feature adds linear byte-stream parsing with a 4KB candidate cap; environment sorting covers only two identity pairs plus one marker. No nested scalable scans or rescans were introduced.
Cmux Swift Concurrency ✅ Passed The PR diff adds no Dispatch queues, Combine state, completion-handler async APIs, or fire-and-forget Tasks; new notification callbacks are synchronous parser delivery, and existing async code is u...
Cmux Swift @Concurrent ✅ Passed The PR adds no async/nonisolated or @concurrent declarations; its new parser and environment-push work are synchronous, while output routing remains explicitly @MainActor.
Cmux Swiftpm Lockfiles ✅ Passed The PR adds Swift source/test file references only; it changes no Package.swift, .gitignore, or Package.resolved, and adds no Xcode SwiftPM package-reference entries.
Cmux Swift Logging ✅ Passed The PR diff adds no print, debugPrint, dump, NSLog, Logger, or ad hoc production logging. New FileHandle use is test-only; existing cmuxDebugLog is unchanged and DEBUG-guarded.
Cmux User-Facing Error Privacy ✅ Passed The production diff adds no user-facing error or recovery text; environment names stay in internal tmux commands/comments, and notification fields use the existing intended notification path.
Cmux Full Internationalization ✅ Passed The diff adds only protocol/config tokens and forwards decoded remote notification payloads; it adds no static user-facing Swift text, localization keys, catalogs, web messages, or locale metadata.
Cmux Swiftui State Layout ✅ Passed The PR diff adds tmux environment, OSC filtering, mirror routing, and tests; it introduces no SwiftUI views, ObservableObject/@published state, GeometryReader, lazy-row store references, or render-...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR adds remote tmux environment and OSC filtering only; it introduces no standalone NSWindow, NSPanel, controller, SwiftUI Window, or close-shortcut code. The test fixture window is explicitly...
Cmux Source Artifacts ✅ Passed The PR diff contains only Swift product/test sources and Xcode project wiring; no logs, media, caches, temp/artifact directories, or binary additions were found.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The production diff adds mirror behavior and a parser, with no DEBUG/test-only seam, debug/test-named member, or widened wrapper accessor.
Title check ✅ Passed The title clearly identifies the main change: delivering OSC 777/9 notifications from SSH-tmux mirrored panes.
Description check ✅ Passed The description clearly covers the problem, implementation, testing, caveats, and scope, despite omitting some template-only sections.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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 `@cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift`:
- Around line 224-251: Add a test alongside the oversized and maximum-size
notification tests that constructs a complete candidate whose total raw length,
including the ESC and ] prefix bytes, is exactly
RemoteTmuxNotificationOSCFilter.maxBufferedBytes. Assert it is stripped
successfully and produces the expected notification, pinning the
reclassifyCandidate boundary behavior without changing existing oversized cases.

In `@Sources/RemoteTmuxControlConnection`+Commands.swift:
- Around line 44-48: Update the mirror-session command flow around
mirrorEnvironmentCommands to include a set-environment -u command removing
CMUX_SOCKET_PATH for the target in the same batch, while preserving the existing
environment assignments. Extend the relevant test to seed CMUX_SOCKET_PATH and
assert that the mirror operation removes it.

In `@Sources/RemoteTmuxNotificationOSCFilter.swift`:
- Around line 44-45: Update Sources/RemoteTmuxNotificationOSCFilter.swift lines
44-45 by defining the OSC 9 progress prefix 9;4; and excluding it in both
finishCandidate and reclassifyCandidate so progress sequences pass through
unchanged. Update cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift lines
155-160 by adding a counterpart to nonNotifyOsc777SubcommandPassesVerbatim that
verifies ESC ] 9;4;1;50 BEL is preserved verbatim and produces no notification.

In `@Sources/RemoteTmuxSessionMirror.swift`:
- Around line 122-125: Update rebuildTopology to prune notificationFilters
alongside titleFilters, retaining only entries whose pane IDs are present in
livePanes. Place the cleanup next to the existing titleFilters filtering and
preserve the disconnect removeAll behavior.

In `@Sources/RemoteTmuxSessionMirror`+OutputRouting.swift:
- Around line 7-10: Replace the type-level paneNotificationIngress instance with
the existing GhosttyApp.desktopNotificationIngress, threading that ingress
through the mirror composition path and updating affected callers or
initializers. Preserve the shared delivery, retargeting, and flood-policy
behavior, and rely on the property’s existing `@MainActor` isolation without
adding actor isolation to GhosttyDesktopNotificationIngress.
🪄 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: 213f6dc6-83cd-4735-9c89-75dd9d8bf619

📥 Commits

Reviewing files that changed from the base of the PR and between b17c260 and 2dc2a74.

📒 Files selected for processing (10)
  • Sources/RemoteTmuxControlConnection+CommandResults.swift
  • Sources/RemoteTmuxControlConnection+Commands.swift
  • Sources/RemoteTmuxControlConnection.swift
  • Sources/RemoteTmuxController.swift
  • Sources/RemoteTmuxNotificationOSCFilter.swift
  • Sources/RemoteTmuxSessionMirror+OutputRouting.swift
  • Sources/RemoteTmuxSessionMirror.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift
  • cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift

Comment on lines +224 to +251
@Test func oversizedUnfinishedCandidatePassesVerbatim() {
// A prefix-compatible sequence that exceeds the ceiling before its
// terminator is flushed verbatim — never stripped, never retained.
let hugeBody = String(repeating: "A", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes + 16)
let input = "\(ESC)]9;\(hugeBody)\(BEL)after"
let result = run(input)
#expect(result.output == bytes(input))
#expect(result.notifications.isEmpty)
}

@Test func oversizedCandidateSplitAcrossChunksPassesVerbatim() {
let hugeBody = String(repeating: "B", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes)
let result = run([
"\(ESC)]777;notify;t;",
hugeBody,
"tail\(BEL)done",
])
#expect(result.output == bytes("\(ESC)]777;notify;t;\(hugeBody)tail\(BEL)done"))
#expect(result.notifications.isEmpty)
}

@Test func maxSizedCompleteNotificationStillStrips() {
// Just under the ceiling must still work.
let body = String(repeating: "C", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 64)
let result = run("\(ESC)]9;\(body)\(BEL)")
#expect(result.output == bytes(""))
#expect(result.notifications.first?.body == body)
}

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

Pin the exact ceiling boundary.

reclassifyCandidate compares raw.count (payload plus the two ESC ] bytes) against maxBufferedBytes. The current tests probe maxBufferedBytes + 16, maxBufferedBytes, and maxBufferedBytes - 64, so an off-by-one in that comparison stays undetected. Add a case whose candidate ends exactly at the ceiling.

♻️ Proposed test for the exact ceiling
     `@Test` func maxSizedCompleteNotificationStillStrips() {
         // Just under the ceiling must still work.
         let body = String(repeating: "C", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 64)
         let result = run("\(ESC)]9;\(body)\(BEL)")
         `#expect`(result.output == bytes(""))
         `#expect`(result.notifications.first?.body == body)
     }
+
+    `@Test` func candidateEndingExactlyAtCeilingStillStrips() {
+        // `raw` counts the two `ESC ]` bytes plus `9;` plus the body, so this
+        // candidate's last buffered byte lands exactly on the ceiling.
+        let body = String(
+            repeating: "D",
+            count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 4
+        )
+        let result = run("\(ESC)]9;\(body)\(BEL)")
+        `#expect`(result.output == bytes(""))
+        `#expect`(result.notifications.first?.body == body)
+    }
📝 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
@Test func oversizedUnfinishedCandidatePassesVerbatim() {
// A prefix-compatible sequence that exceeds the ceiling before its
// terminator is flushed verbatim — never stripped, never retained.
let hugeBody = String(repeating: "A", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes + 16)
let input = "\(ESC)]9;\(hugeBody)\(BEL)after"
let result = run(input)
#expect(result.output == bytes(input))
#expect(result.notifications.isEmpty)
}
@Test func oversizedCandidateSplitAcrossChunksPassesVerbatim() {
let hugeBody = String(repeating: "B", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes)
let result = run([
"\(ESC)]777;notify;t;",
hugeBody,
"tail\(BEL)done",
])
#expect(result.output == bytes("\(ESC)]777;notify;t;\(hugeBody)tail\(BEL)done"))
#expect(result.notifications.isEmpty)
}
@Test func maxSizedCompleteNotificationStillStrips() {
// Just under the ceiling must still work.
let body = String(repeating: "C", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 64)
let result = run("\(ESC)]9;\(body)\(BEL)")
#expect(result.output == bytes(""))
#expect(result.notifications.first?.body == body)
}
@Test func oversizedUnfinishedCandidatePassesVerbatim() {
// A prefix-compatible sequence that exceeds the ceiling before its
// terminator is flushed verbatim — never stripped, never retained.
let hugeBody = String(repeating: "A", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes + 16)
let input = "\(ESC)]9;\(hugeBody)\(BEL)after"
let result = run(input)
#expect(result.output == bytes(input))
#expect(result.notifications.isEmpty)
}
@Test func oversizedCandidateSplitAcrossChunksPassesVerbatim() {
let hugeBody = String(repeating: "B", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes)
let result = run([
"\(ESC)]777;notify;t;",
hugeBody,
"tail\(BEL)done",
])
#expect(result.output == bytes("\(ESC)]777;notify;t;\(hugeBody)tail\(BEL)done"))
#expect(result.notifications.isEmpty)
}
@Test func maxSizedCompleteNotificationStillStrips() {
// Just under the ceiling must still work.
let body = String(repeating: "C", count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 64)
let result = run("\(ESC)]9;\(body)\(BEL)")
#expect(result.output == bytes(""))
#expect(result.notifications.first?.body == body)
}
@Test func candidateEndingExactlyAtCeilingStillStrips() {
// `raw` counts the two `ESC ]` bytes plus `9;` plus the body, so this
// candidate's last buffered byte lands exactly on the ceiling.
let body = String(
repeating: "D",
count: RemoteTmuxNotificationOSCFilter.maxBufferedBytes - 4
)
let result = run("\(ESC)]9;\(body)\(BEL)")
#expect(result.output == bytes(""))
#expect(result.notifications.first?.body == body)
}
🤖 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 `@cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift` around lines 224 - 251,
Add a test alongside the oversized and maximum-size notification tests that
constructs a complete candidate whose total raw length, including the ESC and ]
prefix bytes, is exactly RemoteTmuxNotificationOSCFilter.maxBufferedBytes.
Assert it is stripped successfully and produces the expected notification,
pinning the reclassifyCandidate boundary behavior without changing existing
oversized cases.

Comment on lines +44 to +48
var pairs = mirrorEnvironment
pairs[Self.mirrorMarkerEnvironmentKey] = "1"
let commands = Self.mirrorEnvironmentCommands(target: target, pairs: pairs)
guard !commands.isEmpty else { return }
_ = sendBatchInternal(commands, kinds: commands.map { _ in .other })

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

socket="cmux-env-review-$$"
session="cmux-env-review-$$"
trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT

tmux -L "$socket" new-session -d -s "$session"
tmux -L "$socket" set-environment -t "$session" CMUX_SOCKET_PATH "/tmp/dead.sock"
tmux -L "$socket" show-environment -t "$session" | grep -Fx "CMUX_SOCKET_PATH=/tmp/dead.sock"

tmux -L "$socket" set-environment -u -t "$session" CMUX_SOCKET_PATH"
! tmux -L "$socket" show-environment -t "$session" | grep -q '^CMUX_SOCKET_PATH='

Repository: manaflow-ai/cmux

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RemoteTmuxControlConnection|Tmux|tmux|Environment|Tests' | head -200

printf '%s\n' '--- symbols and references ---'
rg -n -C 4 \
  'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources Tests . 2>/dev/null | head -500

printf '%s\n' '--- target file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline Sources/RemoteTmuxControlConnection+Commands.swift
else
  wc -l Sources/RemoteTmuxControlConnection+Commands.swift
fi

printf '%s\n' '--- target implementation ---'
sed -n '1,130p' Sources/RemoteTmuxControlConnection+Commands.swift

Repository: manaflow-ai/cmux

Length of output: 18889


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg 'RemoteTmuxControlConnection|Tmux|tmux|Environment|Tests' | head -200

printf '%s\n' '--- symbols and references ---'
rg -n -C 4 \
  'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources Tests . 2>/dev/null | head -500

printf '%s\n' '--- target implementation ---'
sed -n '1,130p' Sources/RemoteTmuxControlConnection+Commands.swift

Repository: manaflow-ai/cmux

Length of output: 18889


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- target file status ---'
ls -l Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true
wc -l Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true

printf '%s\n' '--- target implementation ---'
sed -n '1,140p' Sources/RemoteTmuxControlConnection+Commands.swift 2>&1 || true

printf '%s\n' '--- matching paths ---'
git ls-files | grep -Ei 'remote.*tmux|tmux.*connection|tmux' | head -300 || true

printf '%s\n' '--- all relevant references ---'
grep -RIn --exclude-dir=.git --exclude-dir=Packages --exclude='*.jsonl' \
  -E 'CMUX_SOCKET_PATH|mirrorEnvironmentCommands|pushMirrorSessionEnvironment|mirrorEnvironment|sendBatchInternal' \
  Sources CLI Tests 2>/dev/null | head -500 || true

Repository: manaflow-ai/cmux

Length of output: 32254


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- environment push tests ---'
sed -n '1,260p' cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift

printf '%s\n' '--- environment state and setter ---'
sed -n '170,225p' Sources/RemoteTmuxControlConnection.swift
sed -n '590,630p' Sources/RemoteTmuxControlConnection.swift

printf '%s\n' '--- push call sites ---'
sed -n '220,260p' Sources/RemoteTmuxControlConnection+CommandResults.swift
sed -n '290,325p' Sources/RemoteTmuxController.swift

printf '%s\n' '--- remote shell integration consumers ---'
rg -n -C 6 \
  'show-environment|CMUX_REMOTE_TMUX_MIRROR|CMUX_WORKSPACE_ID|CMUX_SURFACE_ID|CMUX_TERMINAL_LIFECYCLE_ID' \
  Sources CLI cmuxTests 2>/dev/null | head -700 || true

Repository: manaflow-ai/cmux

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- exact mirror marker references ---'
rg -n -C 12 'CMUX_REMOTE_TMUX_MIRROR' . 2>/dev/null | head -250 || true

printf '%s\n' '--- environment setter call sites ---'
rg -n -C 8 'setMirrorEnvironment\(' Sources cmuxTests 2>/dev/null | head -350 || true

printf '%s\n' '--- remote tmux attach/bootstrap references ---'
rg -n -C 8 \
  'tmux -CC|ssh-tmux|RemoteTmux|remote tmux|control-mode|attach-session|new-session' \
  Sources/RemoteTmux*.swift Sources/SSH*.swift Sources/TerminalController+RemoteTmux.swift 2>/dev/null \
  | head -600 || true

printf '%s\n' '--- repository state ---'
git status --short
git diff --stat -- Sources/RemoteTmuxControlConnection+Commands.swift Sources/RemoteTmuxControlConnection.swift Sources/RemoteTmuxControlConnection+CommandResults.swift Sources/RemoteTmuxController.swift cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift

Repository: manaflow-ai/cmux

Length of output: 50372


🌐 Web query:

tmux man page set-environment -u session environment variable show-environment

💡 Result:

In tmux, environment variables are managed through two levels: the global environment and the session-specific environment [1][2]. When the tmux server starts, it initializes the global environment [1][3]. When a new window or pane is created, tmux merges the global and current session environments—with the session-specific value taking precedence if a variable exists in both—to form the environment for that new process [1][2]. Key commands for managing these variables include: set-environment (alias: setenv) This command is used to set, unset, or modify environment variables [1]. Usage: tmux set-environment [-gru] [-t target-session] name [value] [1] -g: Applies the change to the global environment [1][4]. Without this flag, it applies to the specified session (or the current one) [1][4]. -u: Unsets (removes) the specified variable [1]. -r: Indicates the variable should be removed from the environment before starting a new process [1]. show-environment (alias: showenv) This command displays the environment variables for a specific session or the global environment [1][4]. Usage: tmux show-environment [-g] [-t target-session] [name] [1] -g: Displays the global environment [1][4]. -s: (Often used) Formats the output as shell commands (e.g., export VAR=val), which can be evaluated directly by a shell to update the current environment [5]. Important Considerations: 1. Retroactive Updates: Commands like set-environment only affect new windows, panes, or processes created after the command is run [6][7]. They do not retroactively modify the environment of already running shells [6][7]. 2. Synchronizing Existing Shells: To update an existing shell within tmux, you can manually source the output of the show-environment command [7][5]. A common technique is to use a shell hook, such as PROMPT_COMMAND in Bash, to automatically run eval "$(tmux show-environment -s)" each time the prompt is displayed [5]. 3. update-environment: This is a session option that controls which environment variables are automatically copied from the client into the session environment when a new session is created or reattached [1][7]. You can modify this list using set-option -g update-environment "VAR1 VAR2" [7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- concise repository state ---'
git status --short 2>/dev/null || true
git diff --stat -- Sources/RemoteTmuxControlConnection+Commands.swift \
  Sources/RemoteTmuxControlConnection.swift \
  Sources/RemoteTmuxControlConnection+CommandResults.swift \
  Sources/RemoteTmuxController.swift \
  cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift 2>/dev/null || true

printf '%s\n' '--- relevant remote socket consumers ---'
rg -n -C 5 \
  'CMUX_SOCKET_PATH|CMUX_SOCKET|CMUX_REMOTE_TMUX_MIRROR|show-environment' \
  Sources/RemoteTmux*.swift Sources/RemoteInteractiveShellBootstrapBuilder.swift \
  Sources/Workspace+RemoteDisconnectPlaceholder.swift \
  CLI/CMUXCLI+TmuxCompat*.swift 2>/dev/null | head -400 || true

printf '%s\n' '--- exact environment-push source and tests ---'
nl -ba Sources/RemoteTmuxControlConnection+Commands.swift | sed -n '15,65p'
nl -ba cmuxTests/RemoteTmuxMirrorEnvironmentPushTests.swift | sed -n '115,215p'

Repository: manaflow-ai/cmux

Length of output: 8174


Clear stale CMUX_SOCKET_PATH in the mirror session.

set-environment does not remove omitted variables. A previously stored path can remain visible through show-environment and route remote cmux commands to a dead socket. Send set-environment -u -t <target> CMUX_SOCKET_PATH in the same batch, and update the test to seed and assert removal.

🤖 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/RemoteTmuxControlConnection`+Commands.swift around lines 44 - 48,
Update the mirror-session command flow around mirrorEnvironmentCommands to
include a set-environment -u command removing CMUX_SOCKET_PATH for the target in
the same batch, while preserving the existing environment assignments. Extend
the relevant test to seed CMUX_SOCKET_PATH and assert that the mirror operation
removes it.

Comment on lines +44 to +45
private static let notifyPrefix = Array("777;notify;".utf8)
private static let osc9Prefix = Array("9;".utf8)

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

OSC 9 subcommands are not discriminated, in the filter or in the tests. osc9Prefix is 9;, so every OSC 9 subcommand is classified as an iTerm2 growl notification, and the suite only exercises 9;<text>. The suite already pins the equivalent OSC 777 behavior with nonNotifyOsc777SubcommandPassesVerbatim, so the OSC 9 gap let the over-match ship untested.

  • Sources/RemoteTmuxNotificationOSCFilter.swift#L44-L45: add a progress-subcommand prefix (9;4;) and exclude it in both finishCandidate and reclassifyCandidate, so the sequence streams through instead of being stripped and reported.
  • cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift#L155-L160: add an OSC 9 counterpart to nonNotifyOsc777SubcommandPassesVerbatim that asserts ESC ] 9;4;1;50 BEL passes verbatim and reports no notification.
📍 Affects 2 files
  • Sources/RemoteTmuxNotificationOSCFilter.swift#L44-L45 (this comment)
  • cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift#L155-L160
🤖 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/RemoteTmuxNotificationOSCFilter.swift` around lines 44 - 45, Update
Sources/RemoteTmuxNotificationOSCFilter.swift lines 44-45 by defining the OSC 9
progress prefix 9;4; and excluding it in both finishCandidate and
reclassifyCandidate so progress sequences pass through unchanged. Update
cmuxTests/RemoteTmuxNotificationOSCFilterTests.swift lines 155-160 by adding a
counterpart to nonNotifyOsc777SubcommandPassesVerbatim that verifies ESC ]
9;4;1;50 BEL is preserved verbatim and produces no notification.

Comment on lines +122 to +125
/// Per-pane filter that intercepts OSC 777/9 desktop-notification escapes
/// from `%output` (stateful across chunk boundaries) so a remote process
/// inside the mirrored session can notify locally (issue #833).
var notificationFilters: [Int: RemoteTmuxNotificationOSCFilter] = [:]

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

notificationFilters is never pruned, so it grows for the life of the mirror.

titleFilters is pruned in rebuildTopology at Line 393 with titleFilters = titleFilters.filter { livePanes.contains($0.key) }. The new notificationFilters map has no equivalent prune. tmux pane ids never recur, so every closed pane leaves a permanent entry. Each entry can also retain up to RemoteTmuxNotificationOSCFilter.maxBufferedBytes buffered candidate bytes, so a long-lived mirror with pane churn keeps both the entry count and the retained bytes growing.

The disconnect-edge removeAll() at Line 244 does not cover this: a connection can stay connected across unlimited pane churn.

Add the same prune next to the existing one.

As per coding guidelines: "Avoid repeated full scans, sorting, filtering, or per-item nested scans over scalable collections in production code ... Use sets, dictionaries, indexes, grouped queries, single-pass plans, caches, pagination, or explicit size bounds."

🐛 Proposed fix in `rebuildTopology` (Line 393)
         cwdByPane = cwdByPane.filter { livePanes.contains($0.key) }
         titleFilters = titleFilters.filter { livePanes.contains($0.key) }
+        notificationFilters = notificationFilters.filter { livePanes.contains($0.key) }
🤖 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/RemoteTmuxSessionMirror.swift` around lines 122 - 125, Update
rebuildTopology to prune notificationFilters alongside titleFilters, retaining
only entries whose pane IDs are present in livePanes. Place the cleanup next to
the existing titleFilters filtering and preserve the disconnect removeAll
behavior.

Source: Coding guidelines

Comment on lines +7 to +10
/// Bounded handoff shared by every mirror, mirroring how the Ghostty OSC
/// callback funnels into ``GhosttyDesktopNotificationIngress`` (same
/// delivery, retargeting, and flood policy).
static let paneNotificationIngress = GhosttyDesktopNotificationIngress()

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate the ingress type, its isolation, and every instance of it.
set -euo pipefail

# The type declaration and its actor isolation.
fd -i 'GhosttyDesktopNotification' --extension swift --exec ast-grep outline {} --items all

# Every construction site and every stored/shared instance.
rg -nP -C4 '\bGhosttyDesktopNotificationIngress\s*\(' --type=swift
rg -nP -C4 '(static|let|var)[^=]*\bGhosttyDesktopNotificationIngress\b' --type=swift

# How the Ghostty OSC desktop-notification callback reaches the ingress.
rg -nP -C6 '\.submit\(GhosttyDesktopNotificationRequest' --type=swift

Repository: manaflow-ai/cmux

Length of output: 873


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ingress implementation ---'
cat -n Sources/GhosttyDesktopNotificationIngress.swift

printf '%s\n' '--- mirror routing file ---'
cat -n Sources/RemoteTmuxSessionMirror+OutputRouting.swift

printf '%s\n' '--- ingress references and submit call sites ---'
rg -n -P -C6 '\bGhosttyDesktopNotificationIngress\b|\.submit\(' Sources --glob '*.swift'

printf '%s\n' '--- Ghostty OSC notification callback references ---'
rg -n -P -C8 'GhosttyDesktopNotificationRequest|desktop.?notification|notification.*ingress|OSC' Sources --glob '*.swift'

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- GhosttyApp owner and isolation ---'
sed -n '300,365p' Sources/GhosttyTerminalView.swift
sed -n '3238,3272p' Sources/GhosttyTerminalView.swift

printf '%s\n' '--- RemoteTmuxSessionMirror declarations and initializers ---'
rg -n -P -C5 '^(final |class |struct |actor )?RemoteTmuxSessionMirror|func init\(' Sources/RemoteTmuxSessionMirror*.swift

printf '%s\n' '--- all ingress construction sites, excluding broad call context ---'
rg -n '\bGhosttyDesktopNotificationIngress\s*\(' --type=swift

printf '%s\n' '--- relevant declarations for static/shared owners ---'
rg -n -P -C3 'desktopNotificationIngress|paneNotificationIngress|static let shared|NotificationDebugEmitter' Sources/GhosttyTerminalView.swift Sources/RemoteTmuxSessionMirror*.swift Sources/NotificationDebugEmitter.swift

Repository: manaflow-ai/cmux

Length of output: 4774


Reuse the existing GhosttyApp.desktopNotificationIngress instead of creating a second ingress. The new instance has an independent 256-request buffer, so local and mirrored notifications do not share the flood budget. Pass the existing ingress through the composition path and remove this type-level runtime instance. GhosttyDesktopNotificationIngress is not actor-isolated; only this property is isolated by the @MainActor extension.

🤖 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/RemoteTmuxSessionMirror`+OutputRouting.swift around lines 7 - 10,
Replace the type-level paneNotificationIngress instance with the existing
GhosttyApp.desktopNotificationIngress, threading that ingress through the mirror
composition path and updating affected callers or initializers. Preserve the
shared delivery, retargeting, and flood-policy behavior, and rely on the
property’s existing `@MainActor` isolation without adding actor isolation to
GhosttyDesktopNotificationIngress.

Source: Coding guidelines

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.

Support notifications over SSH+tmux sessions

1 participant