-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
ssh-tmux: deliver OSC 777/9 notifications from mirrored panes (#833) #10048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| import Foundation | ||
|
|
||
| /// Intercepts OSC desktop-notification escapes in a mirrored pane's output | ||
| /// stream (issue #833). | ||
| /// | ||
| /// A remote process inside an ssh-tmux mirror emits notifications with the | ||
| /// xterm/urxvt OSC sequences: | ||
| /// - `ESC ] 777;notify;<title>;<body> BEL|ST` (rxvt-unicode / wezterm style) | ||
| /// - `ESC ] 9;<body> BEL|ST` (iTerm2 growl style; no title) | ||
| /// | ||
| /// `%output` is the raw pty copy, so tmux forwards these bytes verbatim to the | ||
| /// mirror. The mirror's Ghostty surface would parse them, but its | ||
| /// `desktop_notification` callback attributes by the surface's local process | ||
| /// TTY — which a manual-mirror surface does not have — and mirror workspaces | ||
| /// are excluded from agent TTY delivery (`AgentDeliveryTargetResolution`). So | ||
| /// the mirror layer intercepts the sequence here, strips it from the stream, | ||
| /// and reports `(title, body)` so the session mirror can attribute the | ||
| /// notification to the pane's surface + workspace itself. | ||
| /// | ||
| /// Any other OSC sequence (titles, hyperlinks, clipboard, non-`notify` | ||
| /// OSC 777 subcommands) passes through byte-identical. | ||
| /// | ||
| /// Stateful across calls: a `%output` chunk can split the sequence at any | ||
| /// byte. While a sequence still *might* be a notification its bytes are | ||
| /// buffered; the moment the payload diverges from both notification prefixes | ||
| /// the buffer is flushed verbatim and the rest of the sequence streams | ||
| /// through unbuffered. An unfinished candidate that exceeds | ||
| /// ``maxBufferedBytes`` is flushed verbatim too, so a hostile or corrupt | ||
| /// stream can never pin memory or swallow output. | ||
| struct RemoteTmuxNotificationOSCFilter { | ||
| private enum State { | ||
| case text // normal passthrough | ||
| case esc // saw ESC, holding it until we know if it's `ESC ]` | ||
| case collect // inside an OSC that may still be a notification; buffering | ||
| case collectEsc // in collect, saw ESC; maybe the `ESC \` terminator | ||
| case passOsc // inside a non-notification OSC; streaming through | ||
| case passOscEsc // in passOsc, saw ESC; maybe the `ESC \` terminator | ||
| } | ||
|
|
||
| /// Ceiling on bytes buffered for an unfinished candidate sequence. An | ||
| /// overflowing sequence is passed through verbatim instead of stripped. | ||
| static let maxBufferedBytes = 4096 | ||
|
|
||
| private static let notifyPrefix = Array("777;notify;".utf8) | ||
| private static let osc9Prefix = Array("9;".utf8) | ||
|
Comment on lines
+44
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| private var state: State = .text | ||
| /// Original bytes of the candidate sequence (`ESC ]` + payload so far), | ||
| /// replayed verbatim when the sequence turns out not to be a notification. | ||
| private var raw: [UInt8] = [] | ||
| /// Payload bytes only (after `ESC ]`), matched against the prefixes and | ||
| /// decoded into `(title, body)` on a hit. | ||
| private var payload: [UInt8] = [] | ||
|
|
||
| /// Creates a filter with no buffered escape-sequence state. | ||
| init() {} | ||
|
|
||
| /// Returns `data` with any complete notification sequences removed, | ||
| /// invoking `onNotification(title, body)` once per hit in stream order. | ||
| /// OSC 9 hits report an empty title. | ||
| mutating func filter( | ||
| _ data: Data, | ||
| onNotification: (_ title: String, _ body: String) -> Void | ||
| ) -> Data { | ||
| // Hot path: not mid-sequence and no ESC in the chunk — nothing to do. | ||
| if state == .text, !data.contains(0x1b) { return data } | ||
| var out = [UInt8]() | ||
| out.reserveCapacity(data.count) | ||
| for byte in data { | ||
| switch state { | ||
| case .text: | ||
| if byte == 0x1b { | ||
| state = .esc // hold the ESC; emit it only if it isn't `ESC ]` | ||
| } else { | ||
| out.append(byte) | ||
| } | ||
| case .esc: | ||
| if byte == UInt8(ascii: "]") { | ||
| raw = [0x1b, byte] | ||
| payload.removeAll(keepingCapacity: true) | ||
| state = .collect | ||
| } else if byte == 0x1b { | ||
| out.append(0x1b) // emit the held ESC, keep holding the new one | ||
| } else { | ||
| out.append(0x1b) | ||
| out.append(byte) | ||
| state = .text | ||
| } | ||
| case .collect: | ||
| if byte == 0x07 { // BEL terminator | ||
| finishCandidate(terminator: [0x07], into: &out, onNotification) | ||
| } else if byte == 0x1b { | ||
| state = .collectEsc | ||
| } else { | ||
| raw.append(byte) | ||
| payload.append(byte) | ||
| reclassifyCandidate(into: &out) | ||
| } | ||
| case .collectEsc: | ||
| if byte == 0x5c { // `ESC \` (ST) terminator | ||
| finishCandidate(terminator: [0x1b, 0x5c], into: &out, onNotification) | ||
| } else { | ||
| // An OSC payload cannot legally contain a bare ESC; treat the | ||
| // sequence as malformed and pass everything through verbatim. | ||
| out.append(contentsOf: raw) | ||
| out.append(0x1b) | ||
| raw.removeAll(keepingCapacity: true) | ||
| payload.removeAll(keepingCapacity: true) | ||
| if byte == 0x1b { | ||
| state = .esc // the new ESC may start a fresh sequence | ||
| } else { | ||
| out.append(byte) | ||
| state = .text | ||
| } | ||
| } | ||
| case .passOsc: | ||
| if byte == 0x07 { | ||
| out.append(byte) | ||
| state = .text | ||
| } else if byte == 0x1b { | ||
| state = .passOscEsc | ||
| } else { | ||
| out.append(byte) | ||
| } | ||
| case .passOscEsc: | ||
| if byte == 0x5c { | ||
| out.append(0x1b) | ||
| out.append(0x5c) | ||
| state = .text | ||
| } else if byte == 0x1b { | ||
| out.append(0x1b) // emit the held ESC, keep holding the new one | ||
| } else { | ||
| out.append(0x1b) | ||
| out.append(byte) | ||
| state = .passOsc | ||
| } | ||
| } | ||
| } | ||
| return Data(out) | ||
| } | ||
|
|
||
| /// While collecting, checks the payload against both notification prefixes | ||
| /// and the buffer ceiling; a sequence that can no longer be a notification | ||
| /// (or grew too large unfinished) is flushed verbatim and the remainder | ||
| /// streams through as a plain OSC. | ||
| private mutating func reclassifyCandidate(into out: inout [UInt8]) { | ||
| if raw.count <= Self.maxBufferedBytes, | ||
| Self.couldMatch(payload, prefix: Self.notifyPrefix) | ||
| || Self.couldMatch(payload, prefix: Self.osc9Prefix) { | ||
| return | ||
| } | ||
| out.append(contentsOf: raw) | ||
| raw.removeAll(keepingCapacity: true) | ||
| payload.removeAll(keepingCapacity: true) | ||
| state = .passOsc | ||
| } | ||
|
|
||
| /// Whether `payload` is still compatible with `prefix` (either side is a | ||
| /// prefix of the other). | ||
| private static func couldMatch(_ payload: [UInt8], prefix: [UInt8]) -> Bool { | ||
| payload.count >= prefix.count | ||
| ? payload.starts(with: prefix) | ||
| : prefix.starts(with: payload) | ||
| } | ||
|
|
||
| /// A candidate sequence reached its terminator: strip + report a | ||
| /// notification hit, or replay the original bytes for anything else | ||
| /// (including a too-short candidate like `ESC ] 9 BEL`). | ||
| private mutating func finishCandidate( | ||
| terminator: [UInt8], | ||
| into out: inout [UInt8], | ||
| _ onNotification: (_ title: String, _ body: String) -> Void | ||
| ) { | ||
| defer { | ||
| raw.removeAll(keepingCapacity: true) | ||
| payload.removeAll(keepingCapacity: true) | ||
| state = .text | ||
| } | ||
| if payload.starts(with: Self.notifyPrefix) { | ||
| let rest = payload.dropFirst(Self.notifyPrefix.count) | ||
| if let separator = rest.firstIndex(of: UInt8(ascii: ";")) { | ||
| onNotification( | ||
| Self.decode(rest[..<separator]), | ||
| Self.decode(rest[rest.index(after: separator)...]) | ||
| ) | ||
| } else { | ||
| // `777;notify;<title>` without a body separator: title only. | ||
| onNotification(Self.decode(rest), "") | ||
| } | ||
| } else if payload.starts(with: Self.osc9Prefix) { | ||
| onNotification("", Self.decode(payload.dropFirst(Self.osc9Prefix.count))) | ||
| } else { | ||
| out.append(contentsOf: raw) | ||
| out.append(contentsOf: terminator) | ||
| } | ||
| } | ||
|
|
||
| private static func decode(_ bytes: ArraySlice<UInt8>) -> String { | ||
| String(decoding: bytes, as: UTF8.self) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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:
Repository: manaflow-ai/cmux
Length of output: 196
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 18889
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 18889
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 32254
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 50372
🏁 Script executed:
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:
Repository: manaflow-ai/cmux
Length of output: 8174
Clear stale
CMUX_SOCKET_PATHin the mirror session.set-environmentdoes not remove omitted variables. A previously stored path can remain visible throughshow-environmentand route remotecmuxcommands to a dead socket. Sendset-environment -u -t <target> CMUX_SOCKET_PATHin the same batch, and update the test to seed and assert removal.🤖 Prompt for AI Agents