From a716960d122a5cdc1507e5af4d85dbb083210299 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 20 Aug 2026 21:56:19 +0100 Subject: [PATCH 1/8] feat(sessions): link tabs and focus panes under tmux --- .../EnvVarTerminalIntegrationTests.swift | 18 ++++++ .../TmuxFocusTests.swift | 44 +++++++++++++++ notify.sh | 9 ++- panel/Panel.swift | 55 ++++++++++++++++++- panel/SessionStore.swift | 9 +++ panel/TerminalIntegration.swift | 11 ++++ panel/TmuxFocus.swift | 51 +++++++++++++++++ shared/AppActivator.swift | 47 ++++++++++++++++ 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift create mode 100644 panel/TmuxFocus.swift diff --git a/Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift b/Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift index cf6dc6c..97d2818 100644 --- a/Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift +++ b/Tests/StackNudgePanelCoreTests/EnvVarTerminalIntegrationTests.swift @@ -65,4 +65,22 @@ final class EnvVarTerminalIntegrationTests: XCTestCase { let result = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TERM_SESSION_ID") XCTAssertTrue(result.isEmpty) } + + func test_parseEnvValues_tmuxPane() { + // tmux integration keys off TMUX_PANE; the value starts with "%". + let raw = "99028 /bin/zsh TMUX=/private/tmp/tmux-502/default,12390,0 TMUX_PANE=%4 LC_TERMINAL=iTerm2" + let result = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE") + XCTAssertEqual(result[99028], "%4") + } + + func test_parseEnvValues_tmuxVarDoesNotMatchPaneVar() { + // The needle is "=", so a scan for TMUX must not be satisfied by + // TMUX_PANE=… (the "_" breaks the "TMUX=" match). Order the pane var + // first to prove the socket var is the one found. + let raw = "99028 /bin/zsh TMUX_PANE=%4 TMUX=/private/tmp/tmux-502/default,12390,0" + let socket = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX") + let pane = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE") + XCTAssertEqual(socket[99028], "/private/tmp/tmux-502/default,12390,0") + XCTAssertEqual(pane[99028], "%4") + } } diff --git a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift new file mode 100644 index 0000000..680965d --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift @@ -0,0 +1,44 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Pure-parse tests for the tmux focus resolver. The live path (`target`) needs +// `ps eww` against a real tmux pane, but `parse` is where the extraction rules +// live and is fully pure. +final class TmuxFocusTests: XCTestCase { + + func test_parse_extractsPaneSocketAndHost() { + let raw = "99028 /bin/zsh TMUX=/private/tmp/tmux-502/default,12390,0 TMUX_PANE=%4 LC_TERMINAL=iTerm2" + let target = TmuxFocus.parse(psOutput: raw, pid: 99028) + XCTAssertEqual(target?.pane, "%4") + // TMUX is ",," — only the socket path. + XCTAssertEqual(target?.socket, "/private/tmp/tmux-502/default") + XCTAssertEqual(target?.hostBundleID, "com.googlecode.iterm2") + } + + func test_parse_nilWhenNotInTmux() { + // No TMUX_PANE → the process isn't inside tmux. + let raw = "99028 /bin/zsh TERM_PROGRAM=iTerm.app ITERM_SESSION_ID=w0t1p0:ABC" + XCTAssertNil(TmuxFocus.parse(psOutput: raw, pid: 99028)) + } + + func test_parse_socketNilWhenTmuxUnset() { + // A pane var with no TMUX socket (unusual, but must not crash): socket + // is nil and focus falls back to the default socket. + let raw = "42 /bin/zsh TMUX_PANE=%1 LC_TERMINAL=Apple_Terminal" + let target = TmuxFocus.parse(psOutput: raw, pid: 42) + XCTAssertEqual(target?.pane, "%1") + XCTAssertNil(target?.socket) + XCTAssertEqual(target?.hostBundleID, "com.apple.Terminal") + } + + func test_hostBundleID_knownHosts() { + XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "iTerm2"), "com.googlecode.iterm2") + XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "Apple_Terminal"), "com.apple.Terminal") + } + + func test_hostBundleID_unknownOrNilIsNil() { + XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "WezTerm")) + XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: nil)) + } +} diff --git a/notify.sh b/notify.sh index d5158f2..c239efa 100755 --- a/notify.sh +++ b/notify.sh @@ -466,7 +466,12 @@ walk_session_chain() { "Cursor Helper"|"Cursor Helper (Plugin)"|"Cursor Helper (Renderer)"|Cursor|\ "Antigravity Helper"|"Antigravity Helper (Plugin)"|"Antigravity Helper (Renderer)"|Antigravity|\ Zed|zed|\ - iTerm2|iTerm|Terminal|Warp|WarpTerminal|ghostty|Ghostty) + iTerm2|iTerm|Terminal|Warp|WarpTerminal|ghostty|Ghostty|\ + tmux) + # tmux severs the process tree from the host terminal (the agent runs + # under the tmux server, parented to launchd), so the emulator is never + # in the chain. Record the server itself; the panel keys the pane off + # TMUX_PANE (session id below) and focus reads the live env. TERMINAL_PID="$pid"; TERMINAL_APP="$base"; break ;; esac pid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ') @@ -516,7 +521,7 @@ post_to_panel() { NUDGE_TERMINAL_PID="${TERMINAL_PID:-}" \ NUDGE_TERMINAL_APP="${TERMINAL_APP:-}" \ NUDGE_TERM_PROGRAM="${TERM_PROGRAM:-}" \ - NUDGE_SESSION_ID="${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}" \ + NUDGE_SESSION_ID="${TMUX_PANE:-${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}}" \ NUDGE_ITERM_TAB_NAME="${ITERM_TAB_NAME:-}" \ NUDGE_HOOK_JSON="$hook_json" \ python3 - <<'PY' 2>/dev/null diff --git a/panel/Panel.swift b/panel/Panel.swift index e7518ab..f3247dd 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2058,6 +2058,16 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return } + // tmux-hosted event: no bundleID resolves (TERM_PROGRAM=tmux), so focus + // the pane via the tmux server. activate-immediately doesn't hide the + // panel, so no settle wait. + if config.activateImmediately, + event.termProgram == "tmux" || event.terminalApp == "tmux", + let agentPID = event.agentPID, + let target = TmuxFocus.target(agentPID: agentPID) { + dispatchTmuxFocus(target, settle: false) + return + } if config.activateImmediately, let bundleID = event.bundleID { DispatchQueue.global(qos: .userInitiated).async { AppActivator.activate(bundleID: bundleID, @@ -2316,6 +2326,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } let approve = response.actionIdentifier == "ALLOW" + + // tmux-hosted event: focus the pane via the tmux server (no bundleID + // resolves under tmux). The permission decision rides the FIFO, not a + // keystroke, so `approve` doesn't apply here. + if event.termProgram == "tmux" || event.terminalApp == "tmux", + let agentPID = event.agentPID, + let target = TmuxFocus.target(agentPID: agentPID) { + NSApp.hide(nil) + dispatchTmuxFocus(target, settle: true) + return + } guard let bundleID = event.bundleID else { return } // Hide the app first so the system restores focus to the previous @@ -2979,6 +3000,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // banner-click path. The 0.15s settle lets our deactivation land before // the target is raised (without it an approval keystroke can hit our // own process instead of the target's key window). + // tmux-hosted event: focus the pane via the tmux server (no bundleID + // resolves under tmux). + if event.termProgram == "tmux" || event.terminalApp == "tmux", + let agentPID = event.agentPID, + let target = TmuxFocus.target(agentPID: agentPID) { + hidePanel() + dispatchTmuxFocus(target, settle: true) + return + } guard let bundleID = event.bundleID else { return } hidePanel() DispatchQueue.global(qos: .userInitiated).async { @@ -3064,10 +3094,31 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } + // Route a resolved tmux pane to AppActivator on a background queue. `settle` + // waits after a panel/app hide so StackNudge has resigned frontmost before + // the pane is raised (matches the AppActivator.activate call sites). + private func dispatchTmuxFocus(_ target: TmuxFocus.Target, settle: Bool) { + DispatchQueue.global(qos: .userInitiated).async { + if settle { Thread.sleep(forTimeInterval: 0.15) } + AppActivator.focusTmux(pane: target.pane, + socket: target.socket, + hostBundleID: target.hostBundleID) + } + } + private func focusSelectedSession() { guard let pid = sessions.selectedPID, - let session = sessions.sessions.first(where: { $0.pid == pid }), - let bundleID = bundleID(for: session.terminalApp) else { return } + let session = sessions.sessions.first(where: { $0.pid == pid }) + else { return } + // tmux: no terminalApp→bundleID mapping applies (the host emulator isn't + // in the process tree), so focus the pane via the tmux server instead. + if session.terminalApp == "tmux", + let target = TmuxFocus.target(agentPID: session.pid) { + hidePanel() + dispatchTmuxFocus(target, settle: true) + return + } + guard let bundleID = bundleID(for: session.terminalApp) else { return } hidePanel() // session.tabId is the per-tab identity our terminal integrations // captured, but the underlying value differs per terminal — so it diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index aeebd41..24bdc0a 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -560,6 +560,15 @@ final class SessionStore: ObservableObject { private static func canonicalTerminalApp(_ processName: String) -> String? { if terminalApps.contains(processName) { return processName } if processName.hasPrefix("iTermServer") { return "iTerm2" } + // tmux severs the process tree from the host terminal: the agent runs + // under the tmux *server* (parented to launchd), so the host emulator + // (iTerm2/Terminal/…) is never in the parent chain to walk up to. Left + // unmapped, every session inside tmux gets no terminalApp and is + // dropped from enrichment/focus. Recognise the server itself; the + // per-pane tabId comes from TMUX_PANE via the tmux EnvVarTerminal + // integration, and host-terminal focus (LC_TERMINAL + `tmux + // select-pane`) is handled in AppActivator. + if processName == "tmux" { return "tmux" } return nil } diff --git a/panel/TerminalIntegration.swift b/panel/TerminalIntegration.swift index 8666e04..d56e196 100644 --- a/panel/TerminalIntegration.swift +++ b/panel/TerminalIntegration.swift @@ -57,6 +57,17 @@ enum TerminalRegistry { terminalApps: ["Ghostty", "ghostty"], envVar: "TERM_SESSION_ID" ), + // tmux: SessionStore.walkParentChain dead-ends at the tmux server and + // emits terminalApp "tmux" (the host emulator isn't in the parent + // chain). TMUX_PANE (e.g. "%4") is the stable per-pane id, unique + // within a server. No tab name — tmux exposes none via env. Focus into + // the pane (and, under iTerm2 `-CC`, the mapped tab) is AppActivator's + // job, not this conformer's. + EnvVarTerminalIntegration( + name: "tmux", + terminalApps: ["tmux"], + envVar: "TMUX_PANE" + ), ] static func enrich(_ sessions: [Session]) -> [Session] { diff --git a/panel/TmuxFocus.swift b/panel/TmuxFocus.swift new file mode 100644 index 0000000..a3c3819 --- /dev/null +++ b/panel/TmuxFocus.swift @@ -0,0 +1,51 @@ +import Foundation + +// Resolves a tmux-hosted agent to the values AppActivator needs to focus its +// pane. tmux severs the process tree from the host terminal — the agent runs +// under the tmux server (parented to launchd), so none of the usual terminal +// enrichment reaches iTerm2/Terminal. Instead we read the agent process's live +// environment (TMUX socket, TMUX_PANE, LC_TERMINAL) at focus time. Reading it +// live rather than storing it keeps custom sockets and the host terminal +// current, and a dead pid simply yields nil (focus becomes a no-op). +enum TmuxFocus { + + struct Target: Equatable { + let pane: String // TMUX_PANE, e.g. "%4" + let socket: String? // tmux server socket path; nil → default socket + let hostBundleID: String? // app to raise; nil → rely on -CC tab surfacing + } + + // iTerm2 and Terminal.app propagate LC_TERMINAL through tmux/ssh. Only map + // the hosts we can actually raise; anything else leaves hostBundleID nil, + // so focus still selects the pane and (under iTerm2 `-CC`) the mapped tab + // still surfaces. + static func hostBundleID(forLCTerminal lcTerminal: String?) -> String? { + switch lcTerminal { + case "iTerm2": return "com.googlecode.iterm2" + case "Apple_Terminal": return "com.apple.Terminal" + default: return nil + } + } + + // Live resolve: read the agent pid's environment and pull the tmux identity. + static func target(agentPID: Int) -> Target? { + let raw = ProcessOutput.read( + "/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)]) + return parse(psOutput: raw, pid: agentPID) + } + + // Pure: given `ps eww` output and the pid, extract the tmux target. Returns + // nil when the process isn't inside tmux (no TMUX_PANE). Reuses the generic + // env-var parser so the extraction rules stay in one place. + static func parse(psOutput raw: String, pid: Int) -> Target? { + let panes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE") + guard let pane = panes[pid], !pane.isEmpty else { return nil } + // TMUX is ",," — the socket is the part + // before the first comma; tmux -S wants just that path. + let socket = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX")[pid] + .flatMap { $0.split(separator: ",").first.map(String.init) } + let host = hostBundleID(forLCTerminal: + EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "LC_TERMINAL")[pid]) + return Target(pane: pane, socket: socket, hostBundleID: host) + } +} diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 0f6115d..561f820 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -482,4 +482,51 @@ struct AppActivator { } return false } + + // MARK: - tmux + + // Focus a tmux pane by talking to the tmux server (select-window resolves + // the pane's window; select-pane focuses the pane), then raise the host + // terminal. Under iTerm2 `-CC` control mode the window select surfaces the + // mapped native tab; under plain tmux it switches the active pane inside + // the host's single window. socket nil → tmux default socket. hostBundleID + // nil → skip the raise (rely on -CC surfacing the tab). Callers resolve the + // pane/socket/host via TmuxFocus and dispatch this on a background queue. + static func focusTmux(pane: String, socket: String?, hostBundleID: String?) { + guard let tmux = tmuxPath() else { return } + var base: [String] = [] + if let socket, !socket.isEmpty { base += ["-S", socket] } + runDetached(tmux, base + ["select-window", "-t", pane]) + runDetached(tmux, base + ["select-pane", "-t", pane]) + if let hostBundleID, !hostBundleID.isEmpty { + NSRunningApplication + .runningApplications(withBundleIdentifier: hostBundleID) + .first? + .activate(options: [.activateIgnoringOtherApps]) + } + } + + // Resolve the tmux binary from common install locations. A launchd-spawned + // app has a minimal PATH, so probe paths directly (same rationale as the + // gh/claude resolvers). Self-contained here to keep shared/ independent of + // panel/'s ProcessOutput. + private static func tmuxPath() -> String? { + let home = NSHomeDirectory() + return [ + "/opt/homebrew/bin/tmux", + "/usr/local/bin/tmux", + "\(home)/.local/bin/tmux", + "/usr/bin/tmux", + ].first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func runDetached(_ path: String, _ args: [String]) { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + task.standardOutput = Pipe() + task.standardError = Pipe() + try? task.run() + task.waitUntilExit() + } } From 7c80e6dd926a5f63b67190659a4ce687daa4d11b Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 20 Aug 2026 23:33:04 +0100 Subject: [PATCH 2/8] feat(sessions): surface iTerm2 -CC tab by matching pane title --- shared/AppActivator.swift | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 561f820..06ba48d 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -498,6 +498,21 @@ struct AppActivator { if let socket, !socket.isEmpty { base += ["-S", socket] } runDetached(tmux, base + ["select-window", "-t", pane]) runDetached(tmux, base + ["select-pane", "-t", pane]) + + // iTerm2 `-CC`: external tmux selection doesn't surface the native tab, + // and the tab has no tty to match on. The one handle iTerm2 exposes is + // that its `-CC` session name mirrors the tmux pane_title — so select + // the iTerm2 session whose name equals the target pane's live title, + // which brings that exact tab + split to the front. Read the title live + // (both sides track it, so they agree at focus time). Ambiguous only + // when two panes share a title; other hosts just get an app raise. + if hostBundleID == "com.googlecode.iterm2" { + let title = runCapture( + tmux, base + ["display-message", "-p", "-t", pane, "#{pane_title}"])? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let title, !title.isEmpty, selectITermSessionByName(title) { return } + } + if let hostBundleID, !hostBundleID.isEmpty { NSRunningApplication .runningApplications(withBundleIdentifier: hostBundleID) @@ -506,6 +521,45 @@ struct AppActivator { } } + // Select the iTerm2 session whose name matches `name` and bring it forward. + // Under `-CC` the session name mirrors the tmux pane_title, so this focuses + // the exact tab + split. Returns false when no session matches (title + // changed, or not iTerm2) so the caller falls back to a plain app raise. + @discardableResult + private static func selectITermSessionByName(_ name: String) -> Bool { + let escaped = name + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "iTerm2" + activate + set target to "\(escaped)" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + try + if (name of s) is target then + tell w to select + tell t to select + tell s to select + return "matched" + end if + end try + end repeat + end repeat + end repeat + return "no-match" + end tell + """ + var err: NSDictionary? + let result = NSAppleScript(source: script)?.executeAndReturnError(&err) + guard err == nil else { + logScriptError(err, "tmux-iterm2-title") + return false + } + return result?.stringValue == "matched" + } + // Resolve the tmux binary from common install locations. A launchd-spawned // app has a minimal PATH, so probe paths directly (same rationale as the // gh/claude resolvers). Self-contained here to keep shared/ independent of @@ -529,4 +583,17 @@ struct AppActivator { try? task.run() task.waitUntilExit() } + + private static func runCapture(_ path: String, _ args: [String]) -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + let out = Pipe() + task.standardOutput = out + task.standardError = Pipe() + do { try task.run() } catch { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return String(data: data, encoding: .utf8) + } } From 0823d378dbb30968124359dddba599ce1faa0df1 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 20 Aug 2026 23:49:13 +0100 Subject: [PATCH 3/8] fix(sessions): match iTerm2 -CC tab title unicode-safely --- shared/AppActivator.swift | 46 ++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 06ba48d..bfd57eb 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -526,38 +526,48 @@ struct AppActivator { // the exact tab + split. Returns false when no session matches (title // changed, or not iTerm2) so the caller falls back to a plain app raise. @discardableResult - private static func selectITermSessionByName(_ name: String) -> Bool { - let escaped = name - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = """ + private static func selectITermSessionByName(_ title: String) -> Bool { + // Match in Swift, not AppleScript. NSAppleScript mangles non-ASCII + // string literals (Claude's "✳ …" titles decode as MacRoman), and + // `system attribute` mangles them the same way — so ASCII titles + // (codex/agy) matched but Claude titles never did. Reading (id, name) + // OUT is UTF-8-faithful, so enumerate here, match the title in Swift, + // and select by the ASCII GUID via the proven session-id path. Returns + // false when nothing matches (title changed, or not iTerm2) so the + // caller falls back to a plain app raise. + let listScript = """ tell application "iTerm2" - activate - set target to "\(escaped)" + set out to "" repeat with w in windows repeat with t in tabs of w repeat with s in sessions of t try - if (name of s) is target then - tell w to select - tell t to select - tell s to select - return "matched" - end if + set out to out & (unique id of s) & "|" & (name of s) & linefeed end try end repeat end repeat end repeat - return "no-match" + return out end tell """ var err: NSDictionary? - let result = NSAppleScript(source: script)?.executeAndReturnError(&err) - guard err == nil else { - logScriptError(err, "tmux-iterm2-title") + let listed = NSAppleScript(source: listScript)?.executeAndReturnError(&err) + guard err == nil, let out = listed?.stringValue else { + logScriptError(err, "tmux-iterm2-list") return false } - return result?.stringValue == "matched" + // GUIDs never contain "|", so split on the first one; the name (which + // may) is everything after it. + var guid: String? + for line in out.split(separator: "\n") { + guard let bar = line.firstIndex(of: "|") else { continue } + if String(line[line.index(after: bar)...]) == title { + guid = String(line[.. Date: Fri, 21 Aug 2026 09:52:39 +0100 Subject: [PATCH 4/8] chore(sessions): debug-log tmux focus resolution behind panel flag --- panel/TmuxFocus.swift | 13 ++++++++++++- shared/AppActivator.swift | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/panel/TmuxFocus.swift b/panel/TmuxFocus.swift index a3c3819..8c7cfdd 100644 --- a/panel/TmuxFocus.swift +++ b/panel/TmuxFocus.swift @@ -31,7 +31,18 @@ enum TmuxFocus { static func target(agentPID: Int) -> Target? { let raw = ProcessOutput.read( "/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)]) - return parse(psOutput: raw, pid: agentPID) + let resolved = parse(psOutput: raw, pid: agentPID) + debug("target(pid=\(agentPID)) -> " + (resolved.map { + "pane=\($0.pane) socket=\($0.socket ?? "default") host=\($0.hostBundleID ?? "nil")" + } ?? "nil (no TMUX_PANE in that pid's env)")) + return resolved + } + + // Gated on STACKNUDGE_PANEL_DEBUG (same switch AppActivator uses). Off by + // default; surfaces what the running app resolved for a focus attempt. + static func debug(_ message: @autoclosure () -> String) { + guard ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil else { return } + FileHandle.standardError.write(Data("TmuxFocus: \(message())\n".utf8)) } // Pure: given `ps eww` output and the pid, extract the tmux target. Returns diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index bfd57eb..a8594f7 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -493,7 +493,10 @@ struct AppActivator { // nil → skip the raise (rely on -CC surfacing the tab). Callers resolve the // pane/socket/host via TmuxFocus and dispatch this on a background queue. static func focusTmux(pane: String, socket: String?, hostBundleID: String?) { - guard let tmux = tmuxPath() else { return } + guard let tmux = tmuxPath() else { + tmuxDebug("focusTmux: no tmux binary on the probe paths") + return + } var base: [String] = [] if let socket, !socket.isEmpty { base += ["-S", socket] } runDetached(tmux, base + ["select-window", "-t", pane]) @@ -510,10 +513,13 @@ struct AppActivator { let title = runCapture( tmux, base + ["display-message", "-p", "-t", pane, "#{pane_title}"])? .trimmingCharacters(in: .whitespacesAndNewlines) - if let title, !title.isEmpty, selectITermSessionByName(title) { return } + let matched = title.map { !$0.isEmpty && selectITermSessionByName($0) } ?? false + tmuxDebug("focusTmux pane=\(pane) title=«\(title ?? "")» iterm-select=\(matched)") + if matched { return } } if let hostBundleID, !hostBundleID.isEmpty { + tmuxDebug("focusTmux pane=\(pane) → app-raise \(hostBundleID)") NSRunningApplication .runningApplications(withBundleIdentifier: hostBundleID) .first? @@ -606,4 +612,11 @@ struct AppActivator { task.waitUntilExit() return String(data: data, encoding: .utf8) } + + // Gated on STACKNUDGE_PANEL_DEBUG (same switch as logScriptError). Local to + // AppActivator so shared/ stays independent of panel/. + private static func tmuxDebug(_ message: @autoclosure () -> String) { + guard ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil else { return } + FileHandle.standardError.write(Data("AppActivator[tmux]: \(message())\n".utf8)) + } } From 693146f297a5ad947ce8ebda49a8466b6cb16f60 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 21 Aug 2026 12:09:23 +0100 Subject: [PATCH 5/8] fix(sessions): force UTF-8 locale for tmux title read under launchd --- shared/AppActivator.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index a8594f7..ca34e61 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -590,10 +590,22 @@ struct AppActivator { ].first { FileManager.default.isExecutableFile(atPath: $0) } } + // tmux renders non-ASCII in formats like #{pane_title} as "_" unless its + // client is UTF-8, which it decides from LC_ALL/LC_CTYPE/LANG. The + // launchd-spawned panel inherits no locale, so Claude's "✳ …" titles came + // back as "_ …" and never matched the iTerm2 session name. Force a UTF-8 + // locale on the tmux subprocess so the real bytes come through. + private static func tmuxEnv() -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["LC_ALL"] = "en_US.UTF-8" + return env + } + private static func runDetached(_ path: String, _ args: [String]) { let task = Process() task.executableURL = URL(fileURLWithPath: path) task.arguments = args + task.environment = tmuxEnv() task.standardOutput = Pipe() task.standardError = Pipe() try? task.run() @@ -604,6 +616,7 @@ struct AppActivator { let task = Process() task.executableURL = URL(fileURLWithPath: path) task.arguments = args + task.environment = tmuxEnv() let out = Pipe() task.standardOutput = out task.standardError = Pipe() From d0f40ae91a076217a38400858ac04611159f63e7 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 21 Aug 2026 13:33:32 +0100 Subject: [PATCH 6/8] fix(sessions): harden tmux focus (host/fall-through/tab-id/threading) --- .../TmuxFocusTests.swift | 12 ++-- .../TmuxIntegrationTests.swift | 29 ++++++++++ notify.sh | 13 ++++- panel/Panel.swift | 56 ++++++++++--------- panel/TerminalIntegration.swift | 14 ++--- panel/TmuxFocus.swift | 23 ++++---- panel/TmuxIntegration.swift | 46 +++++++++++++++ 7 files changed, 141 insertions(+), 52 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/TmuxIntegrationTests.swift create mode 100644 panel/TmuxIntegration.swift diff --git a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift index 680965d..abf4f48 100644 --- a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift +++ b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift @@ -25,19 +25,21 @@ final class TmuxFocusTests: XCTestCase { func test_parse_socketNilWhenTmuxUnset() { // A pane var with no TMUX socket (unusual, but must not crash): socket // is nil and focus falls back to the default socket. - let raw = "42 /bin/zsh TMUX_PANE=%1 LC_TERMINAL=Apple_Terminal" + let raw = "42 /bin/zsh TMUX_PANE=%1 LC_TERMINAL=iTerm2" let target = TmuxFocus.parse(psOutput: raw, pid: 42) XCTAssertEqual(target?.pane, "%1") XCTAssertNil(target?.socket) - XCTAssertEqual(target?.hostBundleID, "com.apple.Terminal") + XCTAssertEqual(target?.hostBundleID, "com.googlecode.iterm2") } - func test_hostBundleID_knownHosts() { + func test_hostBundleID_iTerm2() { XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "iTerm2"), "com.googlecode.iterm2") - XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "Apple_Terminal"), "com.apple.Terminal") } - func test_hostBundleID_unknownOrNilIsNil() { + func test_hostBundleID_unmappableHostsAreNil() { + // Terminal.app doesn't propagate LC_TERMINAL through tmux, so it (and + // any other host) resolves to nil — pane select still happens, no raise. + XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "Apple_Terminal")) XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: "WezTerm")) XCTAssertNil(TmuxFocus.hostBundleID(forLCTerminal: nil)) } diff --git a/Tests/StackNudgePanelCoreTests/TmuxIntegrationTests.swift b/Tests/StackNudgePanelCoreTests/TmuxIntegrationTests.swift new file mode 100644 index 0000000..14868b9 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/TmuxIntegrationTests.swift @@ -0,0 +1,29 @@ +import XCTest + +@testable import StackNudgePanelCore + +final class TmuxIntegrationTests: XCTestCase { + + func test_tabId_composesServerAndPane() { + // TMUX = ",," → ":". + let id = TmuxIntegration.tabId(pane: "%4", tmux: "/private/tmp/tmux-502/default,12390,0") + XCTAssertEqual(id, "12390:%4") + } + + func test_tabId_fallsBackToBarePaneWhenTmuxMissing() { + XCTAssertEqual(TmuxIntegration.tabId(pane: "%1", tmux: nil), "%1") + } + + func test_tabId_fallsBackWhenTmuxMalformed() { + // No comma → no server field; empty server field → also fall back. + XCTAssertEqual(TmuxIntegration.tabId(pane: "%1", tmux: "nocommas"), "%1") + XCTAssertEqual(TmuxIntegration.tabId(pane: "%2", tmux: "/sock,,0"), "%2") + } + + func test_tabId_distinctAcrossServers() { + // Same pane id in two different servers must not collide. + let a = TmuxIntegration.tabId(pane: "%1", tmux: "/sockA,111,0") + let b = TmuxIntegration.tabId(pane: "%1", tmux: "/sockB,222,0") + XCTAssertNotEqual(a, b) + } +} diff --git a/notify.sh b/notify.sh index c239efa..7ae97e8 100755 --- a/notify.sh +++ b/notify.sh @@ -501,6 +501,17 @@ post_to_panel() { local hook_json="$HOOK_JSON" (( ${#hook_json} > 32768 )) && hook_json="" + # tmux tab identity: ":" from TMUX=",,", + # matching TmuxIntegration.tabId so events and sessions share the id. Unique + # across multiple tmux servers, unlike a bare pane id. Outside tmux, fall back + # to the terminal's own session id. + local session_id + if [[ -n "${TMUX:-}" && -n "${TMUX_PANE:-}" ]]; then + session_id="$(printf '%s' "$TMUX" | cut -d, -f2):${TMUX_PANE}" + else + session_id="${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}" + fi + NUDGE_AGENT="$AGENT" \ NUDGE_EVENT="$EVENT" \ NUDGE_TITLE="$1" \ @@ -521,7 +532,7 @@ post_to_panel() { NUDGE_TERMINAL_PID="${TERMINAL_PID:-}" \ NUDGE_TERMINAL_APP="${TERMINAL_APP:-}" \ NUDGE_TERM_PROGRAM="${TERM_PROGRAM:-}" \ - NUDGE_SESSION_ID="${TMUX_PANE:-${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}}" \ + NUDGE_SESSION_ID="$session_id" \ NUDGE_ITERM_TAB_NAME="${ITERM_TAB_NAME:-}" \ NUDGE_HOOK_JSON="$hook_json" \ python3 - <<'PY' 2>/dev/null diff --git a/panel/Panel.swift b/panel/Panel.swift index f3247dd..6a9d792 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2058,14 +2058,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return } - // tmux-hosted event: no bundleID resolves (TERM_PROGRAM=tmux), so focus - // the pane via the tmux server. activate-immediately doesn't hide the - // panel, so no settle wait. + // tmux-hosted event: no bundleID resolves (TERM_PROGRAM=tmux). Resolve + // the pane + focus off the main thread. Exclusive — a tmux event never + // falls through to the bundle path below, which would raise Terminal.app + // (notify.sh's default bundle for an unknown TERM_PROGRAM). if config.activateImmediately, - event.termProgram == "tmux" || event.terminalApp == "tmux", - let agentPID = event.agentPID, - let target = TmuxFocus.target(agentPID: agentPID) { - dispatchTmuxFocus(target, settle: false) + event.termProgram == "tmux" || event.terminalApp == "tmux" { + if let agentPID = event.agentPID { + dispatchTmuxFocus(agentPID: agentPID, settle: false) + } return } if config.activateImmediately, let bundleID = event.bundleID { @@ -2329,12 +2330,13 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // tmux-hosted event: focus the pane via the tmux server (no bundleID // resolves under tmux). The permission decision rides the FIFO, not a - // keystroke, so `approve` doesn't apply here. - if event.termProgram == "tmux" || event.terminalApp == "tmux", - let agentPID = event.agentPID, - let target = TmuxFocus.target(agentPID: agentPID) { - NSApp.hide(nil) - dispatchTmuxFocus(target, settle: true) + // keystroke, so `approve` doesn't apply here. Exclusive — return even if + // the pane can't be resolved, so we never fall through and raise Terminal.app. + if event.termProgram == "tmux" || event.terminalApp == "tmux" { + if let agentPID = event.agentPID { + NSApp.hide(nil) + dispatchTmuxFocus(agentPID: agentPID, settle: true) + } return } guard let bundleID = event.bundleID else { return } @@ -3001,12 +3003,13 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // the target is raised (without it an approval keystroke can hit our // own process instead of the target's key window). // tmux-hosted event: focus the pane via the tmux server (no bundleID - // resolves under tmux). - if event.termProgram == "tmux" || event.terminalApp == "tmux", - let agentPID = event.agentPID, - let target = TmuxFocus.target(agentPID: agentPID) { - hidePanel() - dispatchTmuxFocus(target, settle: true) + // resolves under tmux). Exclusive — return even if the pane can't be + // resolved, so we never fall through and raise Terminal.app. + if event.termProgram == "tmux" || event.terminalApp == "tmux" { + if let agentPID = event.agentPID { + hidePanel() + dispatchTmuxFocus(agentPID: agentPID, settle: true) + } return } guard let bundleID = event.bundleID else { return } @@ -3094,12 +3097,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } - // Route a resolved tmux pane to AppActivator on a background queue. `settle` - // waits after a panel/app hide so StackNudge has resigned frontmost before - // the pane is raised (matches the AppActivator.activate call sites). - private func dispatchTmuxFocus(_ target: TmuxFocus.Target, settle: Bool) { + // Resolve the tmux target and focus its pane on a background queue — the + // `ps` read in TmuxFocus.target must not run on the UI queue. `settle` waits + // after a panel/app hide so StackNudge has resigned frontmost before the + // pane is raised (matches the AppActivator.activate call sites). + private func dispatchTmuxFocus(agentPID: Int, settle: Bool) { DispatchQueue.global(qos: .userInitiated).async { if settle { Thread.sleep(forTimeInterval: 0.15) } + guard let target = TmuxFocus.target(agentPID: agentPID) else { return } AppActivator.focusTmux(pane: target.pane, socket: target.socket, hostBundleID: target.hostBundleID) @@ -3112,10 +3117,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, else { return } // tmux: no terminalApp→bundleID mapping applies (the host emulator isn't // in the process tree), so focus the pane via the tmux server instead. - if session.terminalApp == "tmux", - let target = TmuxFocus.target(agentPID: session.pid) { + if session.terminalApp == "tmux" { hidePanel() - dispatchTmuxFocus(target, settle: true) + dispatchTmuxFocus(agentPID: session.pid, settle: true) return } guard let bundleID = bundleID(for: session.terminalApp) else { return } diff --git a/panel/TerminalIntegration.swift b/panel/TerminalIntegration.swift index d56e196..80854ce 100644 --- a/panel/TerminalIntegration.swift +++ b/panel/TerminalIntegration.swift @@ -59,15 +59,11 @@ enum TerminalRegistry { ), // tmux: SessionStore.walkParentChain dead-ends at the tmux server and // emits terminalApp "tmux" (the host emulator isn't in the parent - // chain). TMUX_PANE (e.g. "%4") is the stable per-pane id, unique - // within a server. No tab name — tmux exposes none via env. Focus into - // the pane (and, under iTerm2 `-CC`, the mapped tab) is AppActivator's - // job, not this conformer's. - EnvVarTerminalIntegration( - name: "tmux", - terminalApps: ["tmux"], - envVar: "TMUX_PANE" - ), + // chain). TmuxIntegration composes the server id with TMUX_PANE into a + // per-pane tabId unique across multiple tmux servers. No tab name — tmux + // exposes none via env. Focus into the pane (and, under iTerm2 `-CC`, + // the mapped tab) is AppActivator's job, not this conformer's. + TmuxIntegration.shared, ] static func enrich(_ sessions: [Session]) -> [Session] { diff --git a/panel/TmuxFocus.swift b/panel/TmuxFocus.swift index 8c7cfdd..6e8aeca 100644 --- a/panel/TmuxFocus.swift +++ b/panel/TmuxFocus.swift @@ -15,22 +15,23 @@ enum TmuxFocus { let hostBundleID: String? // app to raise; nil → rely on -CC tab surfacing } - // iTerm2 and Terminal.app propagate LC_TERMINAL through tmux/ssh. Only map - // the hosts we can actually raise; anything else leaves hostBundleID nil, - // so focus still selects the pane and (under iTerm2 `-CC`) the mapped tab - // still surfaces. + // Only iTerm2 gives a usable host signal through tmux: it sets + // LC_TERMINAL=iTerm2, which survives tmux/ssh. Terminal.app sets + // TERM_PROGRAM=Apple_Terminal — which tmux overwrites with "tmux" — and does + // not propagate LC_TERMINAL, and it has no tmux `-CC` integration anyway, so + // there is no reliable way to identify or raise it from here. nil host means + // focus still selects the pane; only the app-raise/tab-surfacing is skipped. static func hostBundleID(forLCTerminal lcTerminal: String?) -> String? { - switch lcTerminal { - case "iTerm2": return "com.googlecode.iterm2" - case "Apple_Terminal": return "com.apple.Terminal" - default: return nil - } + lcTerminal == "iTerm2" ? "com.googlecode.iterm2" : nil } // Live resolve: read the agent pid's environment and pull the tmux identity. + // Runs on a background queue (callers dispatch), with a timeout so a hung + // `ps` can't wedge the focus path. static func target(agentPID: Int) -> Target? { - let raw = ProcessOutput.read( - "/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)]) + guard let raw = ProcessOutput.read( + "/bin/ps", ["eww", "-o", "pid=,command=", "-p", String(agentPID)], + timeout: 3) else { return nil } let resolved = parse(psOutput: raw, pid: agentPID) debug("target(pid=\(agentPID)) -> " + (resolved.map { "pane=\($0.pane) socket=\($0.socket ?? "default") host=\($0.hostBundleID ?? "nil")" diff --git a/panel/TmuxIntegration.swift b/panel/TmuxIntegration.swift new file mode 100644 index 0000000..62493f0 --- /dev/null +++ b/panel/TmuxIntegration.swift @@ -0,0 +1,46 @@ +import Foundation + +// Enriches tmux-hosted sessions with a per-pane tabId. A bare TMUX_PANE ("%4") +// is unique only within a single tmux server; a user running multiple servers +// (separate sockets) can have the same %N in each, which would collide the +// per-tab renames/colours keyed on tabId and the event↔session fallback match. +// Compose the server id (the pid in TMUX=",,") with the +// pane so the id is unique across servers. notify.sh builds the same +// ":" for event payloads so the two paths agree. +final class TmuxIntegration: TerminalIntegration { + + static let shared = TmuxIntegration() + + let name = "tmux" + + func enrich(_ sessions: [Session]) -> [Session] { + let pids = sessions.filter { $0.terminalApp == "tmux" }.map(\.pid) + guard !pids.isEmpty else { return sessions } + + // One `ps eww` for both vars — TMUX_PANE (the pane) and TMUX (carries + // the server id). Reuses the generic env-var parser. + let raw = ProcessOutput.read( + "/bin/ps", + ["eww", "-o", "pid=,command=", "-p", pids.map(String.init).joined(separator: ",")]) + let panes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX_PANE") + let tmuxes = EnvVarTerminalIntegration.parseEnvValues(raw, envVar: "TMUX") + guard !panes.isEmpty else { return sessions } + + return sessions.map { session in + guard session.terminalApp == "tmux", let pane = panes[session.pid] else { return session } + var copy = session + copy.tabId = Self.tabId(pane: pane, tmux: tmuxes[session.pid]) + return copy + } + } + + // ":" — serverPID is the second comma-field of TMUX + // (",,"). Falls back to the bare pane when TMUX is + // absent or malformed. Must stay in sync with notify.sh's session-id build. + static func tabId(pane: String, tmux: String?) -> String { + guard let server = tmux?.split(separator: ",").dropFirst().first.map(String.init), + !server.isEmpty + else { return pane } + return "\(server):\(pane)" + } +} From 1c440f470e5e33cf0105de51a3e48cba39948fe0 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 21 Aug 2026 14:13:37 +0100 Subject: [PATCH 7/8] fix(sessions): keep empty fields when parsing TMUX server id --- panel/TmuxIntegration.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/panel/TmuxIntegration.swift b/panel/TmuxIntegration.swift index 62493f0..d0f2c12 100644 --- a/panel/TmuxIntegration.swift +++ b/panel/TmuxIntegration.swift @@ -38,7 +38,12 @@ final class TmuxIntegration: TerminalIntegration { // (",,"). Falls back to the bare pane when TMUX is // absent or malformed. Must stay in sync with notify.sh's session-id build. static func tabId(pane: String, tmux: String?) -> String { - guard let server = tmux?.split(separator: ",").dropFirst().first.map(String.init), + // serverPID is positional (2nd field), so keep empty fields — otherwise + // a malformed ",," would slide the session index into the + // server slot. An empty/absent server field falls back to the bare pane. + guard let server = tmux? + .split(separator: ",", omittingEmptySubsequences: false) + .dropFirst().first.map(String.init), !server.isEmpty else { return pane } return "\(server):\(pane)" From 8fe8d7464772af7c68133cdd249d1685e8acf947 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 21 Aug 2026 14:25:29 +0100 Subject: [PATCH 8/8] fix(sessions): strip animated spinner from tmux title match --- .../TmuxFocusTests.swift | 15 +++++++++++ shared/AppActivator.swift | 26 ++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift index abf4f48..b84b732 100644 --- a/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift +++ b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift @@ -32,6 +32,21 @@ final class TmuxFocusTests: XCTestCase { XCTAssertEqual(target?.hostBundleID, "com.googlecode.iterm2") } + func test_normalizedTitle_stripsAnimatedSpinner() { + // codex renders a braille spinner; different frames must normalize to + // the same stable title so the tmux read and iTerm2 name still match. + XCTAssertEqual(AppActivator.normalizedTitle("⠦ stackone"), "stackone") + XCTAssertEqual(AppActivator.normalizedTitle("⠋ stackone"), + AppActivator.normalizedTitle("⠧ stackone")) + } + + func test_normalizedTitle_leavesStablePrefixesAlone() { + // Claude's "✳" is not a braille glyph; agy has no decoration. + XCTAssertEqual(AppActivator.normalizedTitle("✳ Bump stackvox to version 0.6.0"), + "✳ Bump stackvox to version 0.6.0") + XCTAssertEqual(AppActivator.normalizedTitle("StackOne.local"), "StackOne.local") + } + func test_hostBundleID_iTerm2() { XCTAssertEqual(TmuxFocus.hostBundleID(forLCTerminal: "iTerm2"), "com.googlecode.iterm2") } diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index ca34e61..212be2c 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -562,12 +562,18 @@ struct AppActivator { logScriptError(err, "tmux-iterm2-list") return false } - // GUIDs never contain "|", so split on the first one; the name (which - // may) is everything after it. + // Match on the title with any leading animated-spinner run stripped: + // codex renders a braille spinner ("⠦ stackone") whose frame differs + // between the tmux read and the iTerm2 name a moment later, so an exact + // compare misses whenever it's busy. Stable prefixes (Claude's "✳ …") + // aren't braille, so they're untouched. GUIDs never contain "|", so + // split on the first one; the name (which may) is everything after it. + let wanted = normalizedTitle(title) + guard !wanted.isEmpty else { return false } var guid: String? for line in out.split(separator: "\n") { guard let bar = line.firstIndex(of: "|") else { continue } - if String(line[line.index(after: bar)...]) == title { + if normalizedTitle(String(line[line.index(after: bar)...])) == wanted { guid = String(line[.. String { + var rest = Substring(title) + while let first = rest.first, + let scalar = first.unicodeScalars.first, + scalar.properties.isWhitespace || (0x2800...0x28FF).contains(scalar.value) { + rest = rest.dropFirst() + } + return String(rest) + } + // Resolve the tmux binary from common install locations. A launchd-spawned // app has a minimal PATH, so probe paths directly (same rationale as the // gh/claude resolvers). Self-contained here to keep shared/ independent of