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..b84b732 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/TmuxFocusTests.swift @@ -0,0 +1,61 @@ +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=iTerm2" + let target = TmuxFocus.parse(psOutput: raw, pid: 42) + XCTAssertEqual(target?.pane, "%1") + XCTAssertNil(target?.socket) + 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") + } + + 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 d5158f2..7ae97e8 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 ' ') @@ -496,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" \ @@ -516,7 +532,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="$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..6a9d792 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2058,6 +2058,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return } + // 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" { + if let agentPID = event.agentPID { + dispatchTmuxFocus(agentPID: agentPID, settle: false) + } + return + } if config.activateImmediately, let bundleID = event.bundleID { DispatchQueue.global(qos: .userInitiated).async { AppActivator.activate(bundleID: bundleID, @@ -2316,6 +2327,18 @@ 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. 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 } // Hide the app first so the system restores focus to the previous @@ -2979,6 +3002,16 @@ 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). 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 } hidePanel() DispatchQueue.global(qos: .userInitiated).async { @@ -3064,10 +3097,32 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } + // 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) + } + } + 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" { + hidePanel() + dispatchTmuxFocus(agentPID: session.pid, 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..80854ce 100644 --- a/panel/TerminalIntegration.swift +++ b/panel/TerminalIntegration.swift @@ -57,6 +57,13 @@ 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). 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 new file mode 100644 index 0000000..6e8aeca --- /dev/null +++ b/panel/TmuxFocus.swift @@ -0,0 +1,63 @@ +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 + } + + // 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? { + 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? { + 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")" + } ?? "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 + // 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/panel/TmuxIntegration.swift b/panel/TmuxIntegration.swift new file mode 100644 index 0000000..d0f2c12 --- /dev/null +++ b/panel/TmuxIntegration.swift @@ -0,0 +1,51 @@ +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 { + // 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)" + } +} diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 0f6115d..212be2c 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -482,4 +482,174 @@ 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 { + 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]) + 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) + 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? + .activate(options: [.activateIgnoringOtherApps]) + } + } + + // 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(_ 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" + set out to "" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + try + set out to out & (unique id of s) & "|" & (name of s) & linefeed + end try + end repeat + end repeat + end repeat + return out + end tell + """ + var err: NSDictionary? + let listed = NSAppleScript(source: listScript)?.executeAndReturnError(&err) + guard err == nil, let out = listed?.stringValue else { + logScriptError(err, "tmux-iterm2-list") + return false + } + // 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 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 + // 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) } + } + + // 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() + task.waitUntilExit() + } + + private static func runCapture(_ path: String, _ args: [String]) -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + task.environment = tmuxEnv() + 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) + } + + // 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)) + } }