diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 11b6a234e634..683b31f27390 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -194,6 +194,7 @@ extension CMUXCLI { "swap-pane", "tab-action", "themes", + "tmux", "todo", "top", "tree", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bccd38f807b9..a2b28d6c3783 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4441,6 +4441,13 @@ struct CMUXCLI { client: client, jsonOutput: jsonOutput ) + case "tmux": + try runRemoteTmux( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + local: true + ) case "ssh-pty-attach": let (requestedLifecycleID, attachArgsWithoutLifecycle) = parseOption(commandArgs, name: "--lifecycle-id") let stableLifecycleID = Self.normalizedEnvValue(requestedLifecycleID) ?? UUID().uuidString.lowercased() @@ -8720,13 +8727,17 @@ struct CMUXCLI { ) } - /// Mirrors a remote host's tmux sessions; interactive SSH authentication runs - /// inline in the caller's terminal before retrying over the shared master. + /// Mirrors a host's tmux sessions — a remote host over SSH (`ssh-tmux`), or + /// this machine's tmux server (`tmux`, `local: true`). Interactive SSH + /// authentication runs inline in the caller's terminal before retrying over + /// the shared master; the local server never needs it. private func runRemoteTmux( commandArgs: [String], client: SocketClient, - jsonOutput: Bool + jsonOutput: Bool, + local: Bool = false ) throws { + let commandName = local ? "tmux" : "ssh-tmux" var destination: String? var port: Int? var identityFile: String? @@ -8739,7 +8750,7 @@ struct CMUXCLI { while index < commandArgs.count { let arg = commandArgs[index] switch arg { - case "--port": + case "--port" where !local: guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh-tmux: --port requires a value") } @@ -8748,7 +8759,7 @@ struct CMUXCLI { } port = parsed index += 2 - case "--identity": + case "--identity" where !local: guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh-tmux: --identity requires a path") } @@ -8761,6 +8772,9 @@ struct CMUXCLI { newWindow = true index += 1 default: + if local { + throw CLIError(message: "tmux: unexpected argument '\(arg)' (the local tmux server takes no destination; options: --no-focus, --new-window)") + } if arg.hasPrefix("-") { throw CLIError( message: "ssh-tmux: destination must be or an ssh alias. Use --port/--identity for SSH flags." @@ -8775,11 +8789,17 @@ struct CMUXCLI { } } - guard let destination else { + if !local, destination == nil { throw CLIError(message: "ssh-tmux requires a destination (example: cmux ssh-tmux user@host)") } + let displayTarget = local ? "local tmux" : (destination ?? "") - var params: [String: Any] = ["host": destination] + var params: [String: Any] = [:] + if local { + params["local"] = true + } else if let destination { + params["host"] = destination + } if let port { params["port"] = port } if let identityFile, !identityFile.isEmpty { params["identity_file"] = identityFile } params["activate"] = !noFocus @@ -8788,7 +8808,7 @@ struct CMUXCLI { } // BatchMode discovery can take a couple of seconds; show progress. if !jsonOutput { - print("Connecting to \(destination)…") + print("Connecting to \(displayTarget)…") } // Retry interactive authentication once; never spin on auth-required. @@ -8806,11 +8826,16 @@ struct CMUXCLI { } else { let windowId = (result["window_id"] as? String) ?? "" let count = (result["workspace_ids"] as? [Any])?.count ?? 0 - print("OK host=\(destination) workspaces=\(count) window=\(windowId)") + print("OK host=\(local ? "local" : (destination ?? "")) workspaces=\(count) window=\(windowId)") } return } if (result["auth_required"] as? Bool) == true { + // The local server can never require SSH auth; treat it as the + // protocol violation it would be instead of execing anything. + guard !local, let destination else { + throw CLIError(message: "tmux: unexpected auth-required response for the local tmux server") + } guard !didAuthenticate else { throw CLIError( message: "ssh-tmux: authentication did not open the connection to \(destination)" @@ -8829,7 +8854,7 @@ struct CMUXCLI { } continue } - throw CLIError(message: "ssh-tmux: unexpected response from cmux") + throw CLIError(message: "\(commandName): unexpected response from cmux") } } @@ -15873,6 +15898,26 @@ struct CMUXCLI { """ ) return "\(help)\n\n\(newWindowHelp)" + case "tmux": + return String(localized: "cli.help.tmux", defaultValue: """ + Usage: cmux tmux [--no-focus] [--new-window] + + Mirror this machine's tmux sessions into the current window's sidebar via + tmux control mode (tmux -CC) — no SSH involved. Each session becomes a + workspace, each window a tab, and each multi-pane window a native split. + Changes sync both ways: splits, closes, renames, and new windows made in + cmux propagate to tmux, and tmux-side changes (including from another + attached tmux client) appear in cmux. Requires the "Remote tmux" beta + setting. + + Flags: + --no-focus Do not select the mirror workspace or focus its window + --new-window Open the mirror in a dedicated new window + + Example: + cmux tmux + cmux tmux --new-window + """) case "ssh-session-list": return """ Usage: cmux ssh-session-list [--workspace | --all-workspaces] @@ -35249,6 +35294,7 @@ export default CMUXSessionRestore; new-workspace [--name ] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [-A|--forward-agent] [-a|--no-forward-agent] [--ssh-option <opt>] [--window <id|ref|index>] [--no-focus] [-- <remote-command-args>] ssh-tmux <destination> [--port <n>] [--identity <path>] [--no-focus] [--new-window] + tmux [--no-focus] [--new-window] ssh-session-list [--workspace <id|ref|index> | --all-workspaces] ssh-session-attach --session-id <id> [--workspace <id|ref|index>] [--pane <id|ref|index> | --split <left|right|up|down>] ssh-session-cleanup [--workspace <id|ref|index> | --all-workspaces] (--session-id <id> | --all) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1b6a31ceeac1..1f22922558c5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -34123,6 +34123,131 @@ } } }, + "cli.help.tmux": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nاعكس جلسات tmux على هذا الجهاز داخل الشريط الجانبي للنافذة الحالية عبر وضع تحكم tmux‏ (tmux -CC) — دون استخدام SSH. تصبح كل جلسة مساحة عمل، وكل نافذة علامة تبويب، وكل نافذة متعددة الأجزاء تقسيمًا أصليًا. تتزامن التغييرات في الاتجاهين: التقسيمات والإغلاقات وإعادة التسمية والنوافذ الجديدة في cmux تنتقل إلى tmux، وتظهر تغييرات جانب tmux (بما فيها القادمة من عميل tmux آخر متصل) في cmux. يتطلب تفعيل الإصدار التجريبي \"Remote tmux\".\n\nFlags:\n --no-focus عدم تحديد مساحة العمل المعكوسة أو تنشيط نافذتها\n --new-window فتح الانعكاس في نافذة جديدة مخصصة\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nPreslikava tmux sesije ove mašine u bočnu traku trenutnog prozora putem tmux kontrolnog režima (tmux -CC) — bez SSH-a. Svaka sesija postaje radni prostor, svaki prozor kartica, a prozor s više okana izvorna podjela. Promjene se sinhroniziraju u oba smjera: podjele, zatvaranja, preimenovanja i novi prozori napravljeni u cmux-u prenose se u tmux, a promjene s tmux strane (uključujući one iz drugog povezanog tmux klijenta) pojavljuju se u cmux-u. Potrebno je uključiti beta opciju \"Remote tmux\".\n\nFlags:\n --no-focus ne biraj preslikani radni prostor niti aktiviraj prozor\n --new-window otvori preslikavanje u zasebnom novom prozoru\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nSpejl denne maskines tmux-sessioner i det aktuelle vindues sidebjælke via tmux-kontroltilstand (tmux -CC) — uden SSH. Hver session bliver et arbejdsområde, hvert vindue en fane og hvert vindue med flere ruder en integreret opdeling. Ændringer synkroniseres begge veje: opdelinger, lukninger, omdøbninger og nye vinduer i cmux overføres til tmux, og ændringer på tmux-siden (også fra en anden tilsluttet tmux-klient) vises i cmux. Kræver at betaindstillingen \"Remote tmux\" er slået til.\n\nFlags:\n --no-focus vælg ikke det spejlede arbejdsområde eller aktivér vinduet\n --new-window åbn spejlingen i et dedikeret nyt vindue\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nSpiegelt die tmux-Sitzungen dieses Rechners per tmux-Steuermodus (tmux -CC) in die Seitenleiste des aktuellen Fensters — ganz ohne SSH. Jede Sitzung wird zu einem Arbeitsbereich, jedes Fenster zu einem Tab und jedes Fenster mit mehreren Bereichen zu einer nativen Aufteilung. Änderungen werden in beide Richtungen synchronisiert: In cmux erstellte Aufteilungen, Schließungen, Umbenennungen und neue Fenster werden an tmux übertragen, und Änderungen auf tmux-Seite (auch von einem anderen verbundenen tmux-Client) erscheinen in cmux. Erfordert die Beta-Einstellung „Remote tmux“.\n\nFlags:\n --no-focus Gespiegelten Arbeitsbereich nicht auswählen und Fenster nicht fokussieren\n --new-window Spiegelung in einem eigenen neuen Fenster öffnen\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nMirror this machine's tmux sessions into the current window's sidebar via\ntmux control mode (tmux -CC) — no SSH involved. Each session becomes a\nworkspace, each window a tab, and each multi-pane window a native split.\nChanges sync both ways: splits, closes, renames, and new windows made in\ncmux propagate to tmux, and tmux-side changes (including from another\nattached tmux client) appear in cmux. Requires the \"Remote tmux\" beta\nsetting.\n\nFlags:\n --no-focus Do not select the mirror workspace or focus its window\n --new-window Open the mirror in a dedicated new window\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nRefleja las sesiones de tmux de esta máquina en la barra lateral de la ventana actual mediante el modo de control de tmux (tmux -CC), sin SSH. Cada sesión se convierte en un espacio de trabajo, cada ventana en una pestaña y cada ventana con varios paneles en una división nativa. Los cambios se sincronizan en ambos sentidos: las divisiones, cierres, renombrados y ventanas nuevas hechas en cmux se propagan a tmux, y los cambios del lado de tmux (incluidos los de otro cliente tmux conectado) aparecen en cmux. Requiere la opción beta \"Remote tmux\".\n\nFlags:\n --no-focus No seleccionar el espacio de trabajo reflejado ni enfocar su ventana\n --new-window Abrir el reflejo en una ventana nueva dedicada\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nReflète les sessions tmux de cette machine dans la barre latérale de la fenêtre actuelle via le mode contrôle de tmux (tmux -CC), sans SSH. Chaque session devient un espace de travail, chaque fenêtre un onglet et chaque fenêtre multi-panneaux une division native. Les modifications sont synchronisées dans les deux sens : les divisions, fermetures, renommages et nouvelles fenêtres effectués dans cmux se propagent à tmux, et les changements côté tmux (y compris depuis un autre client tmux attaché) apparaissent dans cmux. Nécessite le réglage bêta « Remote tmux ».\n\nFlags:\n --no-focus Ne pas sélectionner l'espace de travail miroir ni activer sa fenêtre\n --new-window Ouvrir le miroir dans une nouvelle fenêtre dédiée\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nRispecchia le sessioni tmux di questa macchina nella barra laterale della finestra corrente tramite la modalità di controllo di tmux (tmux -CC), senza SSH. Ogni sessione diventa uno spazio di lavoro, ogni finestra una scheda e ogni finestra multi-riquadro una divisione nativa. Le modifiche si sincronizzano in entrambe le direzioni: divisioni, chiusure, rinomine e nuove finestre fatte in cmux si propagano a tmux, e le modifiche lato tmux (anche da un altro client tmux collegato) compaiono in cmux. Richiede l'impostazione beta \"Remote tmux\".\n\nFlags:\n --no-focus Non selezionare lo spazio di lavoro rispecchiato né attivare la sua finestra\n --new-window Apri il mirror in una nuova finestra dedicata\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nこのマシンの tmux セッションを、tmux コントロールモード(tmux -CC)で現在のウィンドウのサイドバーにミラーリングします。SSH は使用しません。各セッションはワークスペースに、各ウィンドウはタブに、複数ペインのウィンドウはネイティブ分割になります。変更は双方向に同期されます。cmux での分割・クローズ・名前変更・新規ウィンドウは tmux に反映され、tmux 側の変更(別の tmux クライアントからの変更を含む)も cmux に表示されます。ベータ設定「Remote tmux」が必要です。\n\nFlags:\n --no-focus ミラーのワークスペースを選択せず、ウィンドウにもフォーカスしない\n --new-window 専用の新しいウィンドウでミラーを開く\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nឆ្លុះសម័យ tmux របស់ម៉ាស៊ីននេះទៅក្នុងរបារចំហៀងនៃបង្អួចបច្ចុប្បន្ន តាមរយៈរបៀបបញ្ជា tmux (tmux -CC) — ដោយមិនប្រើ SSH។ សម័យនីមួយៗក្លាយជាកន្លែងធ្វើការ បង្អួចនីមួយៗក្លាយជាផ្ទាំង ហើយបង្អួចដែលមានផ្ទាំងច្រើនក្លាយជាការបំបែកដើម។ ការផ្លាស់ប្ដូរធ្វើសមកាលកម្មទាំងពីរទិស៖ ការបំបែក ការបិទ ការប្ដូរឈ្មោះ និងបង្អួចថ្មីដែលធ្វើក្នុង cmux ត្រូវបានបញ្ជូនទៅ tmux ហើយការផ្លាស់ប្ដូរខាង tmux (រួមទាំងពីម៉ាស៊ីនភ្ញៀវ tmux ផ្សេងទៀតដែលបានភ្ជាប់) បង្ហាញក្នុង cmux។ ត្រូវការការកំណត់បេតា \"Remote tmux\"។\n\nFlags:\n --no-focus កុំជ្រើសរើសកន្លែងធ្វើការដែលឆ្លុះ ហើយកុំផ្ដោតបង្អួចរបស់វា\n --new-window បើកការឆ្លុះក្នុងបង្អួចថ្មីដាច់ដោយឡែក\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\n이 컴퓨터의 tmux 세션을 tmux 컨트롤 모드(tmux -CC)로 현재 창의 사이드바에 미러링합니다. SSH를 사용하지 않습니다. 각 세션은 작업 공간이 되고, 각 창은 탭이 되며, 여러 패널이 있는 창은 네이티브 분할이 됩니다. 변경 사항은 양방향으로 동기화됩니다. cmux에서 수행한 분할·닫기·이름 변경·새 창은 tmux로 전파되고, tmux 쪽 변경 사항(연결된 다른 tmux 클라이언트의 변경 포함)은 cmux에 나타납니다. \"Remote tmux\" 베타 설정이 필요합니다.\n\nFlags:\n --no-focus 미러 작업 공간을 선택하지 않고 창에 포커스하지 않음\n --new-window 전용 새 창에서 미러 열기\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nSpeil denne maskinens tmux-økter i sidefeltet til det gjeldende vinduet via tmux-kontrollmodus (tmux -CC) — uten SSH. Hver økt blir et arbeidsområde, hvert vindu en fane og hvert vindu med flere ruter en integrert deling. Endringer synkroniseres begge veier: delinger, lukkinger, navneendringer og nye vinduer i cmux overføres til tmux, og endringer på tmux-siden (også fra en annen tilkoblet tmux-klient) vises i cmux. Krever at betainnstillingen \"Remote tmux\" er slått på.\n\nFlags:\n --no-focus ikke velg det speilede arbeidsområdet eller fokuser vinduet\n --new-window åpne speilingen i et eget nytt vindu\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nOdzwierciedla sesje tmux tej maszyny w pasku bocznym bieżącego okna przez tryb sterowania tmux (tmux -CC) — bez SSH. Każda sesja staje się przestrzenią roboczą, każde okno kartą, a okno z wieloma panelami natywnym podziałem. Zmiany synchronizują się w obie strony: podziały, zamknięcia, zmiany nazw i nowe okna wykonane w cmux trafiają do tmux, a zmiany po stronie tmux (także z innego podłączonego klienta tmux) pojawiają się w cmux. Wymaga ustawienia beta „Remote tmux”.\n\nFlags:\n --no-focus Nie wybieraj odzwierciedlonej przestrzeni roboczej ani nie aktywuj jej okna\n --new-window Otwórz lustro w osobnym nowym oknie\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nEspelha as sessões tmux desta máquina na barra lateral da janela atual pelo modo de controle do tmux (tmux -CC), sem SSH. Cada sessão vira um espaço de trabalho, cada janela uma aba e cada janela com vários painéis uma divisão nativa. As mudanças sincronizam nos dois sentidos: divisões, fechamentos, renomeações e novas janelas feitas no cmux propagam para o tmux, e mudanças do lado do tmux (inclusive de outro cliente tmux conectado) aparecem no cmux. Requer a configuração beta \"Remote tmux\".\n\nFlags:\n --no-focus Não selecionar o espaço de trabalho espelhado nem focar sua janela\n --new-window Abrir o espelho em uma nova janela dedicada\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nЗеркалирует tmux-сессии этой машины в боковую панель текущего окна через режим управления tmux (tmux -CC) — без SSH. Каждая сессия становится рабочим пространством, каждое окно — вкладкой, а окно с несколькими панелями — нативным разделением. Изменения синхронизируются в обе стороны: разделения, закрытия, переименования и новые окна из cmux передаются в tmux, а изменения со стороны tmux (в том числе из другого подключённого клиента tmux) появляются в cmux. Требуется бета-настройка «Remote tmux».\n\nFlags:\n --no-focus Не выбирать зеркальное рабочее пространство и не фокусировать его окно\n --new-window Открыть зеркало в отдельном новом окне\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nสะท้อนเซสชัน tmux ของเครื่องนี้ไปยังแถบด้านข้างของหน้าต่างปัจจุบันผ่านโหมดควบคุมของ tmux (tmux -CC) โดยไม่ใช้ SSH แต่ละเซสชันจะกลายเป็นพื้นที่ทำงาน แต่ละหน้าต่างเป็นแท็บ และหน้าต่างที่มีหลายบานหน้าต่างเป็นการแบ่งแบบเนทีฟ การเปลี่ยนแปลงซิงก์สองทาง: การแบ่ง การปิด การเปลี่ยนชื่อ และหน้าต่างใหม่ที่ทำใน cmux จะส่งต่อไปยัง tmux และการเปลี่ยนแปลงฝั่ง tmux (รวมถึงจากไคลเอนต์ tmux อื่นที่เชื่อมต่ออยู่) จะปรากฏใน cmux ต้องเปิดการตั้งค่าเบต้า \"Remote tmux\"\n\nFlags:\n --no-focus ไม่เลือกพื้นที่ทำงานที่สะท้อนและไม่โฟกัสหน้าต่าง\n --new-window เปิดมิเรอร์ในหน้าต่างใหม่โดยเฉพาะ\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nBu makinenin tmux oturumlarını tmux kontrol modu (tmux -CC) üzerinden geçerli pencerenin kenar çubuğuna yansıtır — SSH kullanılmaz. Her oturum bir çalışma alanına, her pencere bir sekmeye ve çok bölmeli her pencere yerel bir bölmeye dönüşür. Değişiklikler iki yönlü eşitlenir: cmux'ta yapılan bölmeler, kapatmalar, yeniden adlandırmalar ve yeni pencereler tmux'a aktarılır; tmux tarafındaki değişiklikler (bağlı başka bir tmux istemcisinden gelenler dahil) cmux'ta görünür. \"Remote tmux\" beta ayarı gerekir.\n\nFlags:\n --no-focus Yansıtılan çalışma alanını seçme ve penceresine odaklanma\n --new-window Yansımayı ayrı yeni bir pencerede aç\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\nВіддзеркалює tmux-сесії цієї машини в бічну панель поточного вікна через режим керування tmux (tmux -CC) — без SSH. Кожна сесія стає робочим простором, кожне вікно — вкладкою, а вікно з кількома панелями — нативним поділом. Зміни синхронізуються в обох напрямках: поділи, закриття, перейменування та нові вікна з cmux передаються в tmux, а зміни з боку tmux (зокрема з іншого під'єднаного клієнта tmux) з'являються в cmux. Потрібне бета-налаштування «Remote tmux».\n\nFlags:\n --no-focus Не вибирати віддзеркалений робочий простір і не фокусувати його вікно\n --new-window Відкрити дзеркало в окремому новому вікні\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\n通过 tmux 控制模式(tmux -CC)将本机的 tmux 会话镜像到当前窗口的侧边栏——无需 SSH。每个会话成为一个工作区,每个窗口成为一个标签页,多窗格窗口成为原生分屏。更改双向同步:在 cmux 中进行的分屏、关闭、重命名和新建窗口会传播到 tmux,tmux 侧的更改(包括来自其他已连接 tmux 客户端的更改)也会出现在 cmux 中。需要启用“Remote tmux”测试版设置。\n\nFlags:\n --no-focus 不选择镜像工作区,也不聚焦其窗口\n --new-window 在专用新窗口中打开镜像\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux tmux [--no-focus] [--new-window]\n\n透過 tmux 控制模式(tmux -CC)將本機的 tmux 工作階段鏡像到目前視窗的側邊欄——無需 SSH。每個工作階段成為一個工作區,每個視窗成為一個分頁,多窗格視窗成為原生分割。變更會雙向同步:在 cmux 中進行的分割、關閉、重新命名與新視窗會傳播到 tmux,tmux 端的變更(包括來自其他已連線 tmux 用戶端的變更)也會顯示在 cmux 中。需要啟用「Remote tmux」測試版設定。\n\nFlags:\n --no-focus 不選取鏡像工作區,也不聚焦其視窗\n --new-window 在專用的新視窗開啟鏡像\n\nExample:\n cmux tmux\n cmux tmux --new-window" + } + } + } + }, "cli.hooks.antigravity.aborted": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4ae69ae1976d..f47334d7bf31 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7343,6 +7343,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // In a window whose selected workspace mirrors the local tmux server, a + // plain New Workspace becomes a real tmux session (mirrored, two-way + // synced) — the workspace-level counterpart of in-mirror new-tab → + // `new-window` routing. Checked after the configured override (an + // explicit user override wins) and only for the plain terminal variant. + if initialSurface == .terminal, + let manager = context?.tabManager ?? preferredTabManager, + remoteTmuxController.routeNewWorkspaceToLocalTmux(in: manager) { + return true + } + if let context, let workspaceGroupTarget { guard let workspace = context.tabManager.createWorkspaceInGroup( groupId: workspaceGroupTarget.groupId, @@ -15366,7 +15377,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .builtIn(let builtIn): switch builtIn { case .newWorkspace: - context.tabManager.addWorkspace() + if !remoteTmuxController.routeNewWorkspaceToLocalTmux(in: context.tabManager) { + context.tabManager.addWorkspace() + } onExecuted?() return true case .newAgentChat: return performConfiguredNewAgentChatAction(context: context, preferredWindow: preferredWindow, onExecuted: onExecuted) diff --git a/Sources/RemoteTmuxConnectionObservers.swift b/Sources/RemoteTmuxConnectionObservers.swift index 17ddcfd37dc3..6f6dba580791 100644 --- a/Sources/RemoteTmuxConnectionObservers.swift +++ b/Sources/RemoteTmuxConnectionObservers.swift @@ -19,6 +19,7 @@ final class RemoteTmuxConnectionObservers { private var paneReflowObservers: [Token: (_ paneId: Int, _ noReflow: Bool) -> Void] = [:] private var activePaneObservers: [Token: (_ windowId: Int, _ paneId: Int) -> Void] = [:] private var sessionChangedObservers: [Token: (_ oldName: String, _ newName: String) -> Void] = [:] + private var sessionsChangedObservers: [Token: () -> Void] = [:] private var topologyObservers: [Token: () -> Void] = [:] private var reconnectReadyObservers: [Token: () -> Void] = [:] private var exitObservers: [Token: () -> Void] = [:] @@ -45,6 +46,10 @@ final class RemoteTmuxConnectionObservers { /// - onSessionChanged: fires when tmux confirms a session name change via /// `%session-changed` or `%session-renamed`; consumers must treat this as /// the authoritative point for re-keying session-owned state. + /// - onSessionsChanged: fires on `%sessions-changed` — the server-wide + /// session SET changed (a session was created or destroyed anywhere on + /// the server, not necessarily the one this connection is attached to). + /// Consumers reconcile the mirrored-session set against fresh discovery. /// - onTopologyChanged: fires when the window/pane topology changes. /// - onReconnectReady: fires after reconnect attach drainage and reseeding. /// - onExit: fires once when the connection PERMANENTLY ends (a genuine tmux @@ -60,6 +65,7 @@ final class RemoteTmuxConnectionObservers { onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)?, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)?, onSessionChanged: ((_ oldName: String, _ newName: String) -> Void)?, + onSessionsChanged: (() -> Void)?, onTopologyChanged: (() -> Void)?, onReconnectReady: (() -> Void)?, onExit: (() -> Void)?, @@ -71,6 +77,7 @@ final class RemoteTmuxConnectionObservers { if let onPaneReflow { paneReflowObservers[token] = onPaneReflow } if let onActivePaneChanged { activePaneObservers[token] = onActivePaneChanged } if let onSessionChanged { sessionChangedObservers[token] = onSessionChanged } + if let onSessionsChanged { sessionsChangedObservers[token] = onSessionsChanged } if let onTopologyChanged { topologyObservers[token] = onTopologyChanged } if let onReconnectReady { reconnectReadyObservers[token] = onReconnectReady } if let onExit { exitObservers[token] = onExit } @@ -85,6 +92,7 @@ final class RemoteTmuxConnectionObservers { paneReflowObservers[token] = nil activePaneObservers[token] = nil sessionChangedObservers[token] = nil + sessionsChangedObservers[token] = nil topologyObservers[token] = nil reconnectReadyObservers[token] = nil exitObservers[token] = nil @@ -118,6 +126,12 @@ final class RemoteTmuxConnectionObservers { for callback in Array(sessionChangedObservers.values) { callback(oldName, newName) } } + /// Notifies every observer that the server-wide session set changed + /// (`%sessions-changed`: a session was created or destroyed on the server). + func notifySessionsChanged() { + for callback in Array(sessionsChangedObservers.values) { callback() } + } + /// Notifies every topology observer that the window/pane layout changed. func notifyTopologyChanged() { for callback in Array(topologyObservers.values) { callback() } diff --git a/Sources/RemoteTmuxControlConnection+Commands.swift b/Sources/RemoteTmuxControlConnection+Commands.swift index 1b8318e67bf2..e84cda040f59 100644 --- a/Sources/RemoteTmuxControlConnection+Commands.swift +++ b/Sources/RemoteTmuxControlConnection+Commands.swift @@ -270,6 +270,11 @@ extension RemoteTmuxControlConnection { sendPerWindowSize(windowId: windowId, columns: size.0, rows: size.1) } } + // A fresh control client starts with no flags, so re-apply a released + // size authority (cmux backgrounded across the reconnect) after re-pinning. + if sizeAuthorityReleased { + send("refresh-client -f ignore-size") + } // The re-applied size is usually a no-op (the server kept the window at our // size across the transport drop), so TUIs get no SIGWINCH — kick them so // they repaint over the re-seeded (possibly stale) frame. FIFO-safe: the diff --git a/Sources/RemoteTmuxControlConnection+Observation.swift b/Sources/RemoteTmuxControlConnection+Observation.swift index e539a29f2a74..c5a1a88f8395 100644 --- a/Sources/RemoteTmuxControlConnection+Observation.swift +++ b/Sources/RemoteTmuxControlConnection+Observation.swift @@ -45,6 +45,9 @@ extension RemoteTmuxControlConnection { /// (e.g. the active pane's directory) onto the window's tab. /// - onSessionChanged: fires when tmux confirms a session name change via /// `%session-changed` or `%session-renamed`. + /// - onSessionsChanged: fires on `%sessions-changed` — the server-wide + /// session SET changed (a session was created or destroyed anywhere on + /// the server, not necessarily this connection's session). /// - onTopologyChanged: fires when the window/pane topology changes. /// - onReconnectReady: fires after reconnect attach drainage and reseeding, /// when observers may safely schedule commands against fresh topology. @@ -61,6 +64,7 @@ extension RemoteTmuxControlConnection { onPaneReflow: ((_ paneId: Int, _ noReflow: Bool) -> Void)? = nil, onActivePaneChanged: ((_ windowId: Int, _ paneId: Int) -> Void)? = nil, onSessionChanged: ((_ oldName: String, _ newName: String) -> Void)? = nil, + onSessionsChanged: (() -> Void)? = nil, onTopologyChanged: (() -> Void)? = nil, onReconnectReady: (() -> Void)? = nil, onExit: (() -> Void)? = nil, @@ -72,6 +76,7 @@ extension RemoteTmuxControlConnection { onPaneReflow: onPaneReflow, onActivePaneChanged: onActivePaneChanged, onSessionChanged: onSessionChanged, + onSessionsChanged: onSessionsChanged, onTopologyChanged: onTopologyChanged, onReconnectReady: onReconnectReady, onExit: onExit, diff --git a/Sources/RemoteTmuxControlConnection+Sizing.swift b/Sources/RemoteTmuxControlConnection+Sizing.swift index a251340f8861..fee02a48f7d3 100644 --- a/Sources/RemoteTmuxControlConnection+Sizing.swift +++ b/Sources/RemoteTmuxControlConnection+Sizing.swift @@ -52,6 +52,55 @@ extension RemoteTmuxControlConnection { synchronizeClientSizeToWindowClaims() } + /// Focus-gated size authority. When the cmux app is not frontmost, cmux + /// releases its hold on the window size so a co-attached real terminal can + /// resize it freely (that terminal no longer shows the filler border cmux's + /// smaller pin caused); when cmux returns to the foreground it reclaims + /// authority and re-imposes its own grid. Uses tmux's `ignore-size` client + /// flag: while set, this control client is excluded from window-size + /// calculation, so windows follow the other clients — and when cmux is the + /// sole client tmux just holds each window's current size (no balloon, + /// measured on 3.5a). The state is stored even while disconnected so the + /// reconnect reseed re-applies it. + func setSizeAuthorityReleased(_ released: Bool) { + guard released != sizeAuthorityReleased else { return } + sizeAuthorityReleased = released + applySizeAuthority() + } + + /// (Re)applies the current size-authority state to the live client — on a + /// state change and from the reconnect reseed (a fresh control client starts + /// with no flags). No-op while not connected; the stored flag drives the + /// next reseed. + func applySizeAuthority() { + guard connectionState == .connected else { return } + if sizeAuthorityReleased { + // Stop constraining: co-attached real clients own the window size. + send("refresh-client -f ignore-size") + return + } + // Reclaim authority: count toward sizing again, then force-resend our + // pins. A co-client may have resized windows while cmux was ignored, and + // the sent-ledger dedup still records the old pins as delivered, so it + // would suppress the reclaim; clearing it makes the pins go back out and + // tmux clamp the windows back to cmux's grid. + send("refresh-client -f '!ignore-size'") + sentWindowSizes.removeAll() + if let size = lastClientSize { + send("refresh-client -C \(size.columns)x\(size.rows)") + } + if supportsPerWindowSize { + for (windowId, size) in lastWindowSizes.sorted(by: { $0.key < $1.key }) { + sendPerWindowSize(windowId: windowId, columns: size.0, rows: size.1) + } + } + // The reclaim resize usually differs from the co-client's size and + // SIGWINCHes the TUIs; when it happens to match, arm the one-shot kick so + // a running TUI is not left painted at the co-client's (now stale) size. + pendingAttachRedrawKick = true + scheduleAttachRedrawKickIfNeeded() + } + /// Keeps the control client's envelope equal to the largest live per-window claims. private func synchronizeClientSizeToWindowClaims() { guard supportsPerWindowSize, diff --git a/Sources/RemoteTmuxControlConnection.swift b/Sources/RemoteTmuxControlConnection.swift index 2926a0bd14e2..c23ee134932a 100644 --- a/Sources/RemoteTmuxControlConnection.swift +++ b/Sources/RemoteTmuxControlConnection.swift @@ -170,6 +170,13 @@ final class RemoteTmuxControlConnection { var windowSizeDebounceTasks: [Int: Task<Void, Never>] = [:] /// Whether the server accepts per-window `refresh-client -C` sizing. var supportsPerWindowSize = true + /// When true, cmux has released its window-size authority for this connection + /// (tmux `refresh-client -f ignore-size`), so co-attached real terminals drive + /// each window's size and cmux imposes no ceiling. Set while the cmux app is + /// not frontmost; cleared — and the pins re-asserted — when it returns. Kept + /// across a reconnect so the fresh control client re-applies it (see + /// ``reseedAfterReconnect()``). + var sizeAuthorityReleased = false /// Instant of the most recent sizing write on this connection — kept for /// diagnostics (how stale is the last size request). var lastSizingSendAt: ContinuousClock.Instant? @@ -296,7 +303,8 @@ final class RemoteTmuxControlConnection { self.createIfMissing = createIfMissing } - /// Spawns the SSH `tmux -CC` process and begins streaming. + /// Spawns the `tmux -CC` control process (over SSH, or directly under a + /// local PTY for ``RemoteTmuxHost/local``) and begins streaming. func start() throws { guard !started else { return } try host.ensureControlSocketDirectory() @@ -381,17 +389,45 @@ final class RemoteTmuxControlConnection { enterReceived = false let proc = Process() - proc.executableURL = URL(fileURLWithPath: RemoteTmuxHost.defaultSSHExecutablePath()) - proc.arguments = host.controlModeArguments( - sessionName: sessionName, - createIfMissing: createIfMissing - ) - let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe() - proc.standardInput = inPipe - proc.standardOutput = outPipe + let errPipe = Pipe() proc.standardError = errPipe + let stdinHandle: FileHandle + let stdoutHandle: FileHandle + // The child's side of a local pty, closed right after launch so the + // master sees the pty's EOF (EIO) when the child exits. + var slaveHandleToClose: FileHandle? + if host.isLocal { + // Local endpoint: run `tmux -CC` directly under a locally-allocated + // PTY (control mode needs a controlling tty; `ssh -tt` supplies it + // remotely, RemoteTmuxLocalPTY supplies it here). Everything from the + // writer/reader down is byte-stream plumbing shared with the ssh path. + let pty = try RemoteTmuxLocalPTY.open() + let argv = host.localControlModeInvocation( + sessionName: sessionName, + createIfMissing: createIfMissing + ) + proc.executableURL = URL(fileURLWithPath: argv[0]) + proc.arguments = Array(argv.dropFirst()) + proc.environment = Self.localControlModeEnvironment() + proc.standardInput = pty.slaveHandle + proc.standardOutput = pty.slaveHandle + stdinHandle = pty.masterWriteHandle + stdoutHandle = pty.masterReadHandle + slaveHandleToClose = pty.slaveHandle + } else { + proc.executableURL = URL(fileURLWithPath: RemoteTmuxHost.defaultSSHExecutablePath()) + proc.arguments = host.controlModeArguments( + sessionName: sessionName, + createIfMissing: createIfMissing + ) + let inPipe = Pipe(), outPipe = Pipe() + proc.standardInput = inPipe + proc.standardOutput = outPipe + stdinHandle = inPipe.fileHandleForWriting + stdoutHandle = outPipe.fileHandleForReading + } let stdinWriter = RemoteTmuxControlPipeWriter( - handle: inPipe.fileHandleForWriting, + handle: stdinHandle, label: "com.cmux.remote-tmux.stdin.\(UUID().uuidString)", maxPendingBytes: Self.maxPendingStdinBytes, onFailure: { [weak self] in @@ -407,8 +443,7 @@ final class RemoteTmuxControlConnection { self?.handleStdoutBackpressureOverflow() } ) - let reader = outPipe.fileHandleForReading - stdoutPipeReader.attach(to: reader) + stdoutPipeReader.attach(to: stdoutHandle) let stderrPipeReader = RemoteTmuxProcessOutputReader( label: "com.cmux.remote-tmux.stderr.\(UUID().uuidString)", maxPendingChunks: Self.maxPendingStderrChunks, @@ -436,11 +471,15 @@ final class RemoteTmuxControlConnection { stdoutPipeReader.close() stderrPipeReader.close() stdinWriter.close() + try? slaveHandleToClose?.close() throw error } + // The child holds its own copy of the pty slave; release the parent's so + // the master reads EIO (stream end) once the local tmux client exits. + try? slaveHandleToClose?.close() process = proc self.stdinWriter = stdinWriter - stdoutReader = reader + stdoutReader = stdoutHandle self.stdoutPipeReader = stdoutPipeReader self.stderrPipeReader = stderrPipeReader processGeneration &+= 1 @@ -463,6 +502,23 @@ final class RemoteTmuxControlConnection { } } + /// The environment for the local `tmux -CC` control client. + /// + /// Strips `TMUX`/`TMUX_PANE` so an app process that somehow inherited a tmux + /// context can't make the client refuse to attach as a "nested" session, and + /// guarantees a `TERM` (GUI apps have none) so the tmux client's tty setup + /// never falls over. Everything else — notably `TMUX_TMPDIR`, which selects + /// the server the whole feature talks to — is inherited unchanged. + private static func localControlModeEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: "TMUX") + environment.removeValue(forKey: "TMUX_PANE") + if (environment["TERM"] ?? "").isEmpty { + environment["TERM"] = "xterm-256color" + } + return environment + } + /// Appends captured stderr, bounded (by UTF-8 bytes) so a noisy/hostile remote /// can't grow it without limit. Keeps the tail (the most recent, where the /// failure reason is). @@ -796,6 +852,10 @@ final class RemoteTmuxControlConnection { applySessionNameChange(sessionId: id, name: renameName, event: "session-renamed", refetchWindows: false) case .sessionsChanged: record("sessions-changed") + // The server-wide session set changed (created/destroyed session, + // possibly out-of-band). Consumers (the controller) reconcile the + // mirrored-session set against fresh discovery. + observers.notifySessionsChanged() case let .windowAdd(id): record("window-add @\(id)") requestWindows() diff --git a/Sources/RemoteTmuxController+Attach.swift b/Sources/RemoteTmuxController+Attach.swift index 2cf999066281..47cf0d5532ba 100644 --- a/Sources/RemoteTmuxController+Attach.swift +++ b/Sources/RemoteTmuxController+Attach.swift @@ -44,6 +44,9 @@ extension RemoteTmuxController { guard !sessions.isEmpty else { throw RemoteTmuxError.unreachable("no tmux sessions on \(host.destination)") } + // Baseline for the `%sessions-changed` reconcile: sessions in this set + // are "seen" — only sessions created after this point auto-mirror. + discoveredSessionIdsByHost[host.connectionHash] = Set(sessions.map(\.id)) try Task.checkCancellation() try await ensureControlMasterReadyForBurst(host: host) @@ -193,6 +196,111 @@ extension RemoteTmuxController { } } + /// Debounced entry for `%sessions-changed`: a session was created or + /// destroyed somewhere on `host`'s server. Re-discovers the session set and + /// mirrors any session that doesn't have a workspace yet, so a + /// `tmux new-session` from a terminal appears in the sidebar without the + /// user re-running the attach command. Destroyed sessions need no handling + /// here — their own control client's `%exit` already tears the mirror down. + func scheduleSessionSetReconcile(host: RemoteTmuxHost) { + let hash = host.connectionHash + sessionSetReconcileTasks[hash]?.cancel() + sessionSetReconcileTasks[hash] = Task { [weak self] in + // Collapse the per-client broadcast burst and let tmux finish + // creating the session before discovery lists it. + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, let self else { return } + self.sessionSetReconcileTasks[hash] = nil + await self.reconcileSessionSet(host: host) + } + } + + /// One `%sessions-changed` reconcile pass: discover the live session set and + /// mirror the sessions NEW since the last discovery into the window already + /// hosting this host's mirrors. Skips (rather than queues) when an explicit + /// attach is in flight — that attach's own discovery will see the new set. + private func reconcileSessionSet(host: RemoteTmuxHost) async { + guard existingMirrorManager(for: host) != nil else { return } + guard windowRegistry.beginAttach(hostHash: host.connectionHash) else { return } + defer { windowRegistry.endAttach(hostHash: host.connectionHash) } + // Never create a session here: an empty server means everything was + // deliberately closed, and resurrecting a session would fight the + // kill-on-close teardown. + guard let sessions = try? await transport(for: host).discoverMirrorSessions(createIfEmpty: false), + !sessions.isEmpty else { return } + let hash = host.connectionHash + let previouslySeen = discoveredSessionIdsByHost[hash] ?? [] + discoveredSessionIdsByHost[hash] = Set(sessions.map(\.id)) + // Only sessions the user hasn't seen before: re-mirroring a known + // session would resurrect a workspace they deliberately detached. + let fresh = sessions.filter { !previouslySeen.contains($0.id) } + guard !fresh.isEmpty else { return } + // Re-resolve after the await: the mirror window can close mid-discovery. + guard let manager = existingMirrorManager(for: host) else { return } + mirrorDiscoveredSessions(host: host, sessions: fresh, into: manager) + } + + /// Routes a user's plain "New Workspace" to the LOCAL tmux server when the + /// window's selected workspace mirrors it: the new workspace is then a real + /// `tmux new-session` (mirrored and two-way synced) instead of an unsynced + /// local orphan — the workspace-level counterpart of the in-mirror new-tab + /// routing that already lands on `new-window`. + /// + /// Returns `true` when the request was taken over (creation continues + /// asynchronously); `false` means the caller creates a plain workspace. + /// Gated on the SELECTED workspace, matching the sidebar's mirror routing: + /// dedicated mirror windows can contain dragged-in local workspaces, and a + /// user working in one of those keeps plain semantics. + @discardableResult + func routeNewWorkspaceToLocalTmux(in manager: TabManager) -> Bool { + guard Self.isEnabled else { return false } + guard let selected = manager.selectedTab, + selected.isRemoteTmuxMirror, + let mirror = sessionMirrors.values.first(where: { $0.mirroredWorkspaceId == selected.id }), + mirror.host.isLocal else { return false } + Task { [weak self] in + await self?.createAndMirrorLocalSession(in: manager) + } + return true + } + + /// Creates a detached session on the local tmux server, mirrors it into + /// `manager` as a new workspace, and selects it. Falls back to a plain + /// workspace on any failure so the user's "New Workspace" never silently + /// does nothing. + private func createAndMirrorLocalSession(in manager: TabManager) async { + let host = RemoteTmuxHost.local + func fallbackToPlainWorkspace() { + guard AppDelegate.shared?.windowId(for: manager) != nil else { return } + manager.addWorkspace() + } + guard let created = try? await transport(for: host).runTmux( + ["new-session", "-d", "-P", "-F", "#{session_id}"] + + RemoteTmuxSSHTransport.localStartDirectoryArgs(host: host) + ), created.succeeded else { + fallbackToPlainWorkspace() + return + } + let newSessionId = created.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard !newSessionId.isEmpty, + let sessions = try? await transport(for: host).discoverMirrorSessions(createIfEmpty: false), + let session = sessions.first(where: { $0.id == newSessionId }) else { + fallbackToPlainWorkspace() + return + } + // Mark seen so the `%sessions-changed` reconcile this creation also + // triggers treats the session as handled (mirroring is idempotent + // regardless — this just avoids a redundant pass). + discoveredSessionIdsByHost[host.connectionHash, default: []].insert(session.id) + // The window can close across the awaits. + guard AppDelegate.shared?.windowId(for: manager) != nil else { return } + mirrorDiscoveredSessions(host: host, sessions: [session], into: manager) + let key = Self.connectionKey(host: host, sessionName: session.name) + if let workspace = sessionMirrors[key]?.mirroredWorkspace { + manager.selectWorkspace(workspace) + } + } + private func selectFirstMirrorWorkspace(for host: RemoteTmuxHost, in tabManager: TabManager) { let hostWorkspaceIds = Set(sessionMirrors.values.compactMap { mirror -> UUID? in guard mirror.host.connectionHash == host.connectionHash else { return nil } diff --git a/Sources/RemoteTmuxController+Decisions.swift b/Sources/RemoteTmuxController+Decisions.swift index 53eca662ec12..4d9420724dd7 100644 --- a/Sources/RemoteTmuxController+Decisions.swift +++ b/Sources/RemoteTmuxController+Decisions.swift @@ -149,14 +149,20 @@ extension RemoteTmuxController { /// `select-window`s the remote, so the selected tab's window is targeted by /// id rather than relying on tmux's current window. /// - /// Working directory: when non-blank, appends `-c '<path>'` so the new tab - /// opens in the active tab's directory (like a local new tab). Without `-c`, - /// tmux uses its default-path. The path is single-quoted so spaces and shell - /// metacharacters survive tmux's parser (the quoting the `rename-*` commands - /// use on this stream); a path carrying CR/LF/control bytes that could - /// terminate the command line is dropped, leaving the placement-only command. - /// Background requests add `-d`; focused requests ask tmux to print the stable - /// new window id so focus can be applied only after the mirror tab exists. + /// Working directory: when a concrete path is known, appends `-c '<path>'` so + /// the new tab opens in the active tab's directory (like a local new tab). The + /// path is single-quoted so spaces and shell metacharacters survive tmux's + /// parser (the quoting the `rename-*` commands use on this stream); a path + /// carrying CR/LF/control bytes that could terminate the command line is + /// treated as unknown. When no concrete path is known, falls back to + /// `-c '#{pane_current_path}'` — the target window's own active-pane + /// directory, which tmux expands server-side — rather than omitting `-c`. + /// Omitting it lets tmux pick the session's start directory, which for a + /// session cmux created is cmux's process cwd (`/` when launched from Finder), + /// so a new tab would land in the filesystem root instead of the current + /// directory. Background requests add `-d`; focused requests ask tmux to print + /// the stable new window id so focus can be applied only after the mirror tab + /// exists. nonisolated static func newWindowCommand( afterWindowId: Int?, workingDirectory: String?, @@ -170,6 +176,11 @@ extension RemoteTmuxController { !directory.isEmpty, RemoteTmuxHost.controlModeLineSafeName(directory) != nil { command += " -c \(RemoteTmuxHost.shellSingleQuoted(directory))" + } else { + // Inherit the target window's active-pane directory (tmux expands the + // format server-side against the `-t` target), so a new tab never + // falls through to the session start dir / cmux's `/` process cwd. + command += " -c '#{pane_current_path}'" } return command } diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 3a119290251b..647fd067e69c 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import CmuxSettings import OSLog @@ -31,7 +32,60 @@ final class RemoteTmuxController { private var connectionsByHostSession: [String: RemoteTmuxControlConnection] = [:] private var connectionObserverTokensByHostSession: [String: RemoteTmuxControlConnection.ObserverToken] = [:] - init() {} + /// Debounce for `%sessions-changed`-driven session-set reconciles, keyed by + /// host `connectionHash`. Every live control client on a server receives the + /// broadcast at once, so N mirrored sessions schedule N reconciles — the + /// per-host task collapses them into one discovery pass. + var sessionSetReconcileTasks: [String: Task<Void, Never>] = [:] + + /// The session ids (`$N`) last seen by discovery, keyed by host + /// `connectionHash`. The `%sessions-changed` reconcile mirrors only sessions + /// NEW relative to this set — never sessions the user saw and chose to leave + /// unmirrored (e.g. a workspace detached-but-kept-open), which an + /// unconditional re-mirror would resurrect as a duplicate workspace. + var discoveredSessionIdsByHost: [String: Set<String>] = [:] + + /// Whether the cmux app is currently frontmost. Drives focus-gated size + /// authority: while cmux is NOT frontmost, every mirror connection releases + /// its window-size hold so a co-attached real terminal can resize freely; when + /// cmux returns to the foreground each connection reclaims and re-imposes its + /// grid. See ``RemoteTmuxControlConnection/setSizeAuthorityReleased(_:)``. + private var appIsFrontmost = NSApplication.shared.isActive + private var appActivationObservers: [NSObjectProtocol] = [] + + init() { + observeAppActivationForSizeAuthority() + } + + /// Registers the frontmost/background observers that gate size authority. The + /// notifications post on the main thread and this type is `@MainActor`, so the + /// `.main`-queue callbacks are already isolated. + private func observeAppActivationForSizeAuthority() { + let center = NotificationCenter.default + appActivationObservers = [ + center.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.setAppFrontmost(true) } + }, + center.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.setAppFrontmost(false) } + }, + ] + } + + /// Fans the app's frontmost state out to every live mirror connection: not + /// frontmost → release size authority (co-attached terminals drive the size); + /// frontmost → reclaim and re-impose cmux's grid. + private func setAppFrontmost(_ frontmost: Bool) { + guard frontmost != appIsFrontmost else { return } + appIsFrontmost = frontmost + for connection in connectionsByHostSession.values { + connection.setSizeAuthorityReleased(!frontmost) + } + } /// Synchronous read of the `remoteTmux` beta flag for AppKit/socket paths /// that run outside the SwiftUI update cycle. Resolves the same catalog key @@ -168,8 +222,24 @@ final class RemoteTmuxController { oldName: oldName, newName: newName ) + }, + onSessionsChanged: { [weak self, weak connection] in + guard let self, let connection else { return } + self.scheduleSessionSetReconcile(host: connection.host) + }, + onConnectionStateChanged: { [weak self, weak connection] state in + // A connection that first reaches (or reconnects to) `.connected` + // while the app is backgrounded must release its size authority + // right away, not wait for the next foreground→background edge. + guard let self, let connection, state == .connected, !self.appIsFrontmost else { return } + connection.applySizeAuthority() } ) + // A connection cached while the app is backgrounded starts released; the + // state observer above applies it once the stream reaches `.connected`. + if !appIsFrontmost { + connection.setSizeAuthorityReleased(true) + } } @discardableResult @@ -236,7 +306,10 @@ final class RemoteTmuxController { throw RemoteTmuxError.commandFailed(exitCode: existing.exitCode, stderr: existing.stderr) } - let created = try await transport.runTmux(["new-session", "-d", "-s", sessionName]) + let created = try await transport.runTmux( + ["new-session", "-d", "-s", sessionName] + + RemoteTmuxSSHTransport.localStartDirectoryArgs(host: host) + ) guard created.succeeded else { if let sshArgv = Self.authRequiredAttachArgv(host: host, result: created) { return sshArgv @@ -447,6 +520,9 @@ final class RemoteTmuxController { func remoteUploadTarget(forSurfaceId surfaceId: UUID) -> TerminalRemoteUploadTarget? { for sessionMirror in sessionMirrors.values where !sessionMirror.connection.exited && sessionMirror.ownsSurface(surfaceId) { + // A local mirror's panes can read macOS paths directly — no upload, + // fall through to the normal local-path insertion. + guard !sessionMirror.host.isLocal else { return nil } return .detectedSSH(sessionMirror.host.detectedSSHSession()) } return nil @@ -744,6 +820,14 @@ final class RemoteTmuxController { func sessionMirror(workspaceId: UUID) -> RemoteTmuxSessionMirror? { sessionMirrors.values.first { $0.mirroredWorkspaceId == workspaceId } } + + /// Whether the mirror for `workspaceId` targets the local tmux server. Used to + /// pick the workspace-close policy: closing a LOCAL mirror kills its session + /// (two-way sync — cmux is the tmux UI), while an SSH mirror detaches and keeps + /// the remote session alive for resume (PR #7264). + func mirrorHostIsLocal(workspaceId: UUID) -> Bool { + sessionMirror(workspaceId: workspaceId)?.host.isLocal == true + } /// Detaches a control client and removes its mirror workspace while leaving /// the remote session alive (#7364). Internal callers that already removed the /// mirror keep the low-level stop-only path, preserving their kill semantics. diff --git a/Sources/RemoteTmuxHost.swift b/Sources/RemoteTmuxHost.swift index 95a9f7718502..2c760a8c9e1a 100644 --- a/Sources/RemoteTmuxHost.swift +++ b/Sources/RemoteTmuxHost.swift @@ -1,18 +1,44 @@ import CmuxFoundation import Foundation -/// Identifies a remote host whose tmux server cmux mirrors over SSH. +/// Identifies a host whose tmux server cmux mirrors — over SSH, or the local +/// machine itself (``local``). /// -/// A host is addressed by its SSH `destination` — either a `~/.ssh/config` +/// An SSH host is addressed by its `destination` — either a `~/.ssh/config` /// alias (e.g. `claude-box`) or an explicit `user@host`. cmux multiplexes /// every operation against the host (discovery commands, the `tmux -CC` /// control client, and one-shot mutations) over a single SSH ControlMaster /// socket derived from the destination, so authentication happens once. +/// The ``local`` endpoint runs the same operations directly against the local +/// tmux server: one-shots via the tmux resolver argv, and the control client +/// under a locally-allocated PTY — no ssh, no ControlMaster, no auth. /// The ssh binary every remote-tmux spawn uses. DEBUG builds honor /// `CMUX_REMOTE_TMUX_SSH_FOR_TESTING` so end-to-end tests can substitute a /// shim that strips the ssh framing and execs the remote command locally — /// the full mirror stack then runs hermetically (no sshd, no network). struct RemoteTmuxHost: Sendable, Equatable, Identifiable { + /// How cmux reaches the host's tmux server. + enum Kind: String, Sendable { + /// Over SSH (a `~/.ssh/config` alias or `user@host`) — the original + /// remote-tmux transport. All ControlMaster/argv machinery applies. + case ssh + /// The tmux server on this machine. tmux runs directly (no ssh, no + /// ControlMaster); the control client gets a locally-allocated PTY. + case local + } + + /// The tmux server on the local machine. One canonical value (not a + /// factory) so every attach/mirror/teardown path keys the same endpoint. + static let local = RemoteTmuxHost( + destination: "local", + port: nil, + identityFile: nil, + kind: .local + ) + + /// Whether this host is the local machine's tmux server (no SSH involved). + var isLocal: Bool { kind == .local } + /// The ssh executable used when the caller doesn't inject one (the /// connection and transport inits both take `sshExecutablePath`). /// @@ -39,6 +65,9 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// Optional explicit identity file (`-i`). `nil` defers to `~/.ssh/config`. let identityFile: String? + /// The transport kind (SSH vs. the local machine). See ``Kind``. + let kind: Kind + /// Stable identity matching the connection-uniqueness key. Two hosts with the /// same destination but a different port/identity are distinct endpoints (see /// ``connectionHash``), so `id` uses ``connectionHash`` rather than the @@ -47,9 +76,14 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { var id: String { connectionHash } init(destination: String, port: Int? = nil, identityFile: String? = nil) { + self.init(destination: destination, port: port, identityFile: identityFile, kind: .ssh) + } + + private init(destination: String, port: Int?, identityFile: String?, kind: Kind) { self.destination = destination self.port = port self.identityFile = identityFile + self.kind = kind } /// A human-readable (but lossy) slug for the destination, used only for @@ -82,6 +116,11 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// distinct endpoints must never collapse onto one socket and risk routing a /// command to the wrong server. var connectionHash: String { + // The local endpoint is its own namespace: the literal "local" can never + // collide with an SSH host's 16-hex digest, so an ssh alias actually + // named `local` still keys a distinct endpoint (and never shares + // controller/mirror state with the local machine's tmux server). + if isLocal { return "local" } let fingerprint = "\(destination)\u{1f}\(port.map(String.init) ?? "")\u{1f}\(identityFile ?? "")" var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV offset basis for byte in fingerprint.utf8 { @@ -164,6 +203,8 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// open, then die with the opaque `unix_listener: … too long` — surfacing it /// here gives a clear, actionable error instead. func ensureControlSocketDirectory() throws { + // No ControlMaster socket exists for the local endpoint. + guard !isLocal else { return } let path = controlSocketPath guard Self.controlSocketPathFitsUnixLimit(path) else { let boundPathBytes = path.utf8.count + Self.opensshTransientSuffixLength @@ -203,6 +244,7 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// `tmux -CC` control client; interactive prompts are handled only by /// ``interactiveAuthInvocation()`` running in the user's terminal. func sshControlArguments(controlPersistSeconds: Int, batchMode: Bool) -> [String] { + assert(!isLocal, "sshControlArguments is meaningless for the local tmux endpoint") // Every ssh-tmux invocation supplies its own remote command (`true`, // `tmux -CC …`, one-shot discovery), which OpenSSH refuses while a // host-configured RemoteCommand is in effect (issue #7246). @@ -279,7 +321,8 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { sshExecutablePath: String = RemoteTmuxHost.defaultSSHExecutablePath(), controlPersistSeconds: Int = 180 ) -> [String] { - [sshExecutablePath] + assert(!isLocal, "the local tmux endpoint never needs interactive SSH auth") + return [sshExecutablePath] + sshControlArguments(controlPersistSeconds: controlPersistSeconds, batchMode: false) + ["-o", "BatchMode=no", "-n", "-T", "--", destination, "true"] } @@ -298,11 +341,26 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { /// tiny `/bin/sh` wrapper, then `exec` it with the original arguments so both /// one-shot probes and `tmux -CC` use the same path behavior. static func tmuxRemoteCommand(arguments: [String]) -> String { - (["/bin/sh", "-c", tmuxResolverShellScript, "cmux-remote-tmux"] + arguments) + tmuxResolverInvocation(arguments: arguments) .map(shellSingleQuoted) .joined(separator: " ") } + /// The direct (no shell re-splitting) argv that resolves and execs the local + /// `tmux` binary with `arguments`. Element 0 is the executable (`/bin/sh`). + /// + /// The local-endpoint counterpart of ``tmuxRemoteCommand(arguments:)``: the + /// same resolver script runs, but as a `Process` argv instead of a + /// single-quoted string for a remote login shell — so arguments (session + /// names included) pass through verbatim with no quoting layer at all. + static func tmuxLocalInvocation(arguments: [String]) -> [String] { + tmuxResolverInvocation(arguments: arguments) + } + + private static func tmuxResolverInvocation(arguments: [String]) -> [String] { + ["/bin/sh", "-c", tmuxResolverShellScript, "cmux-remote-tmux"] + arguments + } + /// Stable stderr marker the resolver emits with exit 127 when no tmux binary is usable. static let tmuxNotFoundSentinel = "cmux-remote-tmux: tmux not found" @@ -336,6 +394,21 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { return value } + /// Builds the direct `Process` argv (element 0 is the executable) that runs + /// `tmux -CC` control mode for `sessionName` on the local machine. + /// + /// The caller must give the spawned process a PTY (`tmux -CC` needs a + /// controlling tty; a bare pipe fails with "tcgetattr failed") — see + /// ``RemoteTmuxLocalPTY``. This is the local counterpart of + /// ``controlModeArguments(sessionName:createIfMissing:controlPersistSeconds:)``, + /// whose `ssh -tt` supplies the tty remotely. + func localControlModeInvocation(sessionName: String, createIfMissing: Bool) -> [String] { + assert(isLocal, "localControlModeInvocation is only for the local tmux endpoint") + return Self.tmuxLocalInvocation(arguments: createIfMissing + ? ["-CC", "new-session", "-A", "-s", sessionName] + : ["-CC", "attach-session", "-t", sessionName]) + } + /// Builds the `ssh` argv (for direct `Process` execution, no shell) that /// runs `tmux -CC` control mode for `sessionName` on this host. /// @@ -354,6 +427,7 @@ struct RemoteTmuxHost: Sendable, Equatable, Identifiable { createIfMissing: Bool, controlPersistSeconds: Int = 180 ) -> [String] { + assert(!isLocal, "controlModeArguments builds ssh argv; use localControlModeInvocation for the local endpoint") var args = ["-tt"] args.append(contentsOf: sshControlArguments( controlPersistSeconds: controlPersistSeconds, diff --git a/Sources/RemoteTmuxLocalPTY.swift b/Sources/RemoteTmuxLocalPTY.swift new file mode 100644 index 000000000000..7db15156425c --- /dev/null +++ b/Sources/RemoteTmuxLocalPTY.swift @@ -0,0 +1,69 @@ +import Darwin +import Foundation + +/// A locally-allocated PTY pair for running `tmux -CC` control mode directly +/// (the ``RemoteTmuxHost/local`` endpoint). +/// +/// tmux control mode requires a controlling terminal — on a bare pipe the +/// client dies with "tcgetattr failed". Over SSH, `ssh -tt` supplies that tty +/// on the remote side while cmux's side stays plain pipes; locally, cmux +/// supplies it itself: the child gets the pty slave as stdin/stdout, and cmux +/// reads/writes the master. stderr stays a separate pipe so failure +/// classification ("no server running", the missing-tmux sentinel, …) keeps +/// working — a pty would merge stderr into the control stream (the reason the +/// e2e ssh shim only wraps `-tt` invocations in `script(1)`). +/// +/// The slave is put in raw mode *before* launch so no cooked-tty translation +/// can touch the control stream in the window before tmux configures the tty +/// itself: no echo (a written command must not bounce back into the parser) +/// and no ONLCR (`\n` → `\r\n`; the parser tolerates stray `\r` from `ssh -tt`, +/// but raw keeps the local stream byte-exact). +struct RemoteTmuxLocalPTY { + /// Reads tmux's control-stream output; owns the master descriptor. + let masterReadHandle: FileHandle + /// Writes commands to tmux; owns a `dup` of the master so the reader's and + /// writer's independent `close()`s can never double-close one descriptor. + let masterWriteHandle: FileHandle + /// The child's stdin/stdout. Owned here; the parent must close it right + /// after launch (the spawned child holds its own copy) so master reads see + /// EIO — the pty's EOF — when the child exits. + let slaveHandle: FileHandle + + static func open() throws -> RemoteTmuxLocalPTY { + var master: Int32 = -1 + var slave: Int32 = -1 + guard openpty(&master, &slave, nil, nil, nil) == 0 else { + throw RemoteTmuxError.launchFailed("openpty: \(String(cString: strerror(errno)))") + } + + var tio = termios() + if tcgetattr(slave, &tio) == 0 { + cfmakeraw(&tio) + _ = tcsetattr(slave, TCSANOW, &tio) + } + // A sane default client size for the brief pre-attach window; once the + // mirror is live, cmux drives sizing with `refresh-client -C` claims and + // a control client never becomes tmux's "latest" client anyway. + var size = winsize(ws_row: 24, ws_col: 80, ws_xpixel: 0, ws_ypixel: 0) + _ = ioctl(slave, TIOCSWINSZ, &size) + + // Keep the parent-side descriptors out of every other child cmux spawns: + // a leaked master in an unrelated long-lived child would hold the pty + // open and delay EOF. (Foundation dup2s the slave for this child itself.) + _ = fcntl(master, F_SETFD, FD_CLOEXEC) + let writeFD = dup(master) + guard writeFD >= 0 else { + let error = String(cString: strerror(errno)) + close(master) + close(slave) + throw RemoteTmuxError.launchFailed("dup pty master: \(error)") + } + _ = fcntl(writeFD, F_SETFD, FD_CLOEXEC) + + return RemoteTmuxLocalPTY( + masterReadHandle: FileHandle(fileDescriptor: master, closeOnDealloc: true), + masterWriteHandle: FileHandle(fileDescriptor: writeFD, closeOnDealloc: true), + slaveHandle: FileHandle(fileDescriptor: slave, closeOnDealloc: true) + ) + } +} diff --git a/Sources/RemoteTmuxSSHTransport.swift b/Sources/RemoteTmuxSSHTransport.swift index 1bf4258ce69d..a791bec81e3b 100644 --- a/Sources/RemoteTmuxSSHTransport.swift +++ b/Sources/RemoteTmuxSSHTransport.swift @@ -1,7 +1,8 @@ import Foundation -/// Runs commands against a remote host's tmux server over a shared SSH -/// ControlMaster connection. +/// Runs commands against a host's tmux server — over a shared SSH +/// ControlMaster connection, or directly when the host is +/// ``RemoteTmuxHost/local``. /// /// This is the non-interactive half of the remote-tmux feature: session /// discovery (`tmux list-sessions`) and one-shot mutations (`new-session`, @@ -11,6 +12,11 @@ import Foundation /// (``RemoteTmuxHost/controlSocketPath``), so the first to connect authenticates /// and the rest are subsecond. /// +/// For the local endpoint every command execs directly (the tmux resolver argv +/// for `tmux …`, `/usr/bin/env` otherwise); the ControlMaster lifecycle — +/// warmup, `ssh -O check`, `ssh -O exit` — degenerates to a no-op, so callers +/// need no local-vs-ssh branching of their own. +/// /// Modeled as an `actor` because it owns the per-host connection lifecycle and /// serializes process launches; reads/writes are `async`. actor RemoteTmuxSSHTransport { @@ -141,12 +147,22 @@ actor RemoteTmuxSSHTransport { try await assertMinimumTmuxVersion(checkClientWhenNoServer: createIfEmpty) var sessions = try await listSessions() if sessions.isEmpty, createIfEmpty { - _ = try? await runTmux(["new-session", "-d"]) + _ = try? await runTmux(["new-session", "-d"] + Self.localStartDirectoryArgs(host: host)) sessions = try await listSessions() } return sessions } + /// `-c <home>` for a session cmux creates on the LOCAL server, so it does not + /// inherit cmux's own process cwd (`/` when launched from Finder) as its start + /// directory and strand every `new-window` without `-c` in the filesystem + /// root. Empty for SSH hosts: a remote `new-session` already starts in the + /// remote login home (sshd runs the command from there), and a local home + /// path would be meaningless remotely. + nonisolated static func localStartDirectoryArgs(host: RemoteTmuxHost) -> [String] { + host.isLocal ? ["-c", NSHomeDirectory()] : [] + } + /// Runs a `tmux <args…>` command on the remote host and returns its result. @discardableResult func runTmux(_ args: [String]) async throws -> RemoteTmuxCommandResult { @@ -163,6 +179,9 @@ actor RemoteTmuxSSHTransport { /// remote tmux resolver; other commands are treated as explicit remote argv. @discardableResult func run(_ remoteArgs: [String]) async throws -> RemoteTmuxCommandResult { + if host.isLocal { + return try await Self.runLocal(remoteArgs) + } try host.ensureControlSocketDirectory() let remoteCommand: String if remoteArgs.first == "tmux" { @@ -180,6 +199,28 @@ actor RemoteTmuxSSHTransport { return try await Self.runProcess(executable: sshExecutablePath, arguments: sshArgs) } + /// Runs a command directly on the local machine (the ``RemoteTmuxHost/local`` + /// endpoint) — same contract as the ssh path in ``run(_:)``, minus the ssh. + /// + /// A leading literal `tmux` selects the shared tmux resolver + /// (``RemoteTmuxHost/tmuxLocalInvocation(arguments:)``), so a Homebrew/MacPorts + /// tmux is found even though a GUI app's PATH lacks those directories — and the + /// missing-tmux sentinel classification stays identical to the remote path. + /// Other commands exec via `/usr/bin/env`. No shell joins the arguments, so + /// nothing needs quoting. + private static func runLocal(_ args: [String]) async throws -> RemoteTmuxCommandResult { + let argv: [String] + if args.first == "tmux" { + argv = RemoteTmuxHost.tmuxLocalInvocation(arguments: Array(args.dropFirst())) + } else { + argv = ["/usr/bin/env"] + args + } + guard let executable = argv.first else { + throw RemoteTmuxError.launchFailed("empty local command") + } + return try await runProcess(executable: executable, arguments: Array(argv.dropFirst())) + } + /// Opens the shared SSH ControlMaster (if it isn't already up) and confirms it /// accepts multiplexed sessions, so the burst of `tmux -CC attach` connections /// the controller fires next — each `ControlMaster=auto` @@ -216,6 +257,8 @@ actor RemoteTmuxSSHTransport { /// creating the dedicated window. @discardableResult func ensureMasterReady() async throws -> Bool { + // The local endpoint has no master to warm; it is always "ready". + if host.isLocal { return true } if let existing = readinessTask { return try await existing.value } @@ -261,6 +304,7 @@ actor RemoteTmuxSSHTransport { /// Tears down the shared SSH master (e.g. when the user removes a host). func shutdownMaster() async { + guard !host.isLocal else { return } _ = try? await Self.runProcess( executable: sshExecutablePath, arguments: ["-O", "exit", "-o", "ControlPath=\(host.controlSocketPath)", "--", host.destination] @@ -279,6 +323,7 @@ actor RemoteTmuxSSHTransport { host: RemoteTmuxHost, sshExecutablePath: String = RemoteTmuxHost.defaultSSHExecutablePath() ) { + guard !host.isLocal else { return } let process = Process() process.executableURL = URL(fileURLWithPath: sshExecutablePath) process.arguments = ["-O", "exit", "-o", "ControlPath=\(host.controlSocketPath)", "--", host.destination] diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1eb8585463c7..fffa8497794b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2006,12 +2006,21 @@ class TabManager: ObservableObject { guard tabs.count > 1 else { return } panelTitleUpdateCoalescer.flushNow() sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - // Closing a mirrored remote tmux workspace DETACHES from the remote session, - // leaving it alive on the server for resume. Killing the session is never a - // side effect of closing a tab (PR #7264 review); it is only ever an explicit - // disconnect action. + // Closing a mirrored tmux workspace: for a LOCAL mirror, kill its tmux + // session — cmux is the tmux UI here, so closing the workspace is closing + // the session (two-way sync with tmux, symmetric with auto-mirroring a new + // session on open). `handleWorkspaceClosed` only kills a still-live session, + // so the tmux→cmux session-ended cleanup that also routes here detaches + // without a redundant kill (#7364). An SSH mirror keeps the detach-for- + // resume behavior (PR #7264 review): killing a remote session is never a + // side effect of a close, only an explicit disconnect. if workspace.isRemoteTmuxMirror { - AppDelegate.shared?.remoteTmuxController.detachMirrorWorkspaceKeptOpenLocally(workspaceId: workspace.id) + let controller = AppDelegate.shared?.remoteTmuxController + if controller?.mirrorHostIsLocal(workspaceId: workspace.id) == true { + controller?.handleWorkspaceClosed(workspaceId: workspace.id) + } else { + controller?.detachMirrorWorkspaceKeptOpenLocally(workspaceId: workspace.id) + } } if recordHistory, workspace.isRestorableInSessionSnapshot, diff --git a/Sources/TerminalController+RemoteTmux.swift b/Sources/TerminalController+RemoteTmux.swift index c00764755d71..b9ed1db20195 100644 --- a/Sources/TerminalController+RemoteTmux.swift +++ b/Sources/TerminalController+RemoteTmux.swift @@ -11,8 +11,9 @@ import os extension TerminalController { /// `remote.tmux.sessions` — list the tmux sessions on a host. /// - /// Params: `host` (required SSH destination/alias), optional `port` (Int), - /// optional `identity_file` (String). + /// Params: `host` (required SSH destination/alias) — or `local: true` for + /// this machine's tmux server — plus optional `port` (Int) and + /// `identity_file` (String) for SSH hosts. nonisolated func v2RemoteTmuxSessions(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) @@ -33,7 +34,13 @@ extension TerminalController { } } - /// Builds a ``RemoteTmuxHost`` from socket params (`host`, `port`, `identity_file`). + /// Builds a ``RemoteTmuxHost`` from socket params (`host`, `port`, + /// `identity_file` — or `local: true` for this machine's tmux server). + /// + /// `local: true` wins outright and ignores the SSH params: the local + /// endpoint has no destination/port/identity, and an explicit boolean can + /// never collide with a real `~/.ssh/config` alias that happens to be named + /// `local`. /// /// Rejects a destination (or identity file) beginning with `-`: even with the /// `--` end-of-options guard in the argv builders, a dash-prefixed @@ -41,6 +48,7 @@ extension TerminalController { /// at the trust boundary is defense in depth against ssh option injection /// (`-oProxyCommand=…` → local command execution). nonisolated static func remoteTmuxHost(from params: [String: Any]) -> RemoteTmuxHost? { + if (params["local"] as? Bool) == true { return .local } guard let destination = (params["host"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), !destination.isEmpty, @@ -82,8 +90,9 @@ extension TerminalController { /// `remote.tmux.attach` — attach a `tmux -CC` control client to a session. /// - /// Params: `host` (required), `session` (required tmux session name), - /// optional `create` (Bool — attach-or-create). Returns the control surface id. + /// Params: `host` (required; or `local: true`), `session` (required tmux + /// session name), optional `create` (Bool — attach-or-create). Returns the + /// control surface id. nonisolated func v2RemoteTmuxAttach(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) @@ -120,8 +129,9 @@ extension TerminalController { } /// `remote.tmux.mirror` — mirror every tmux session on a host as its own - /// sidebar workspace in the resolved window. Params: `host` (required), - /// optional `port`, `identity_file`, `activate`, and routing selectors. + /// sidebar workspace in the resolved window. Params: `host` (required; or + /// `local: true`), optional `port`, `identity_file`, `activate`, and + /// routing selectors. nonisolated func v2RemoteTmuxMirror(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) @@ -163,8 +173,8 @@ extension TerminalController { } /// `remote.tmux.window` — mirror every tmux session on a host into a - /// dedicated new window. Params: `host` (required), optional `port`, - /// `identity_file`, and `activate`. + /// dedicated new window. Params: `host` (required; or `local: true`), + /// optional `port`, `identity_file`, and `activate`. nonisolated func v2RemoteTmuxWindow(id: Any?, params: [String: Any]) -> String { guard RemoteTmuxController.isEnabled else { return v2Error(id: id, code: "disabled", message: String(localized: "socket.remoteTmux.disabled", defaultValue: "remote tmux beta is disabled")) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0c4f0cf702e1..7b657ccfc382 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12468,7 +12468,10 @@ extension Workspace: BonsplitDelegate { if let builtInAction = executable.builtInAction { switch builtInAction { case .newWorkspace: - owningTabManager?.addWorkspace() + if let manager = owningTabManager, + AppDelegate.shared?.remoteTmuxController.routeNewWorkspaceToLocalTmux(in: manager) != true { + manager.addWorkspace() + } case .newAgentChat: performSurfaceTabBarNewAgentChatAction(presentingWindow: presentingWindow) case .cloudVM: _ = AppDelegate.shared?.performCloudVMAction(tabManager: owningTabManager, preferredWindow: presentingWindow, debugSource: "surfaceTabBar.cloudVM") diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cc90e11352d1..dc0be778ac42 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1280,6 +1280,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */; }; F6724602A1B2C3D4E5F67246 /* RemoteTmuxHostRemoteCommandOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6724603A1B2C3D4E5F67246 /* RemoteTmuxHostRemoteCommandOverrideTests.swift */; }; 79380000000000000000000C /* RemoteTmuxLayoutNodePatchingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79380000000000000000000B /* RemoteTmuxLayoutNodePatchingTests.swift */; }; + CD1E0676BA61709DAE7341B4 /* RemoteTmuxLocalEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF9D348E6F0E042703FFEC3 /* RemoteTmuxLocalEndpointTests.swift */; }; + C8CEF20F4BE08C1A93458472 /* RemoteTmuxLocalPTY.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76DB6308BF8C47868692B014 /* RemoteTmuxLocalPTY.swift */; }; 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */; }; 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */; }; 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */; }; @@ -3327,6 +3329,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHost.swift; sourceTree = "<group>"; }; F6724603A1B2C3D4E5F67246 /* RemoteTmuxHostRemoteCommandOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxHostRemoteCommandOverrideTests.swift; sourceTree = "<group>"; }; 79380000000000000000000B /* RemoteTmuxLayoutNodePatchingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLayoutNodePatchingTests.swift; sourceTree = "<group>"; }; + ADF9D348E6F0E042703FFEC3 /* RemoteTmuxLocalEndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLocalEndpointTests.swift; sourceTree = "<group>"; }; + 76DB6308BF8C47868692B014 /* RemoteTmuxLocalPTY.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxLocalPTY.swift; sourceTree = "<group>"; }; 6732BEEF6732BEEF6732B001 /* RemoteTmuxMasterReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMasterReadinessTests.swift; sourceTree = "<group>"; }; 7738B0027738B0027738B002 /* RemoteTmuxMirrorCLIFailClosedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIFailClosedTests.swift; sourceTree = "<group>"; }; 7738B0017738B0017738B001 /* RemoteTmuxMirrorCLIObservabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxMirrorCLIObservabilityTests.swift; sourceTree = "<group>"; }; @@ -5480,6 +5484,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 541716FF0D90CB510EE3004A /* RemoteTmuxSession.swift */, 71D411EFBE33FE85901B9E6A /* RemoteTmuxCommandResult.swift */, 02B8A9858C9804AC9C37D7B4 /* RemoteTmuxHost.swift */, + 76DB6308BF8C47868692B014 /* RemoteTmuxLocalPTY.swift */, 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */, 0A17C0DE0A17C0DE0A17C901 /* RemoteTmuxVersion.swift */, 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */, @@ -6109,6 +6114,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 3F704ED4F177122D7DCD0B01 /* RemoteTmuxSessionRenameTitleTests.swift */, 736200000000000000000005 /* RemoteTmuxMirrorLifecycleTests.swift */, 0DBAA681DEE6F9121D289ABE /* RemoteTmuxMirrorCloseDetachTests.swift */, + ADF9D348E6F0E042703FFEC3 /* RemoteTmuxLocalEndpointTests.swift */, 736200000000000000000007 /* RemoteTmuxMirrorTargetingTests.swift */, 838000000000000000000001 /* RemoteTmuxMirrorRenameTests.swift */, 838000000000000000000003 /* RemoteTmuxMirrorRenameHarness.swift */, @@ -7488,6 +7494,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E1B13CF7FE1A77324A142521 /* RemoteTmuxControlStreamParser.swift in Sources */, 2FAA10BC0D7B50DE57F507A4 /* RemoteTmuxError.swift in Sources */, 240EDE51707EB251790C8985 /* RemoteTmuxHost.swift in Sources */, + C8CEF20F4BE08C1A93458472 /* RemoteTmuxLocalPTY.swift in Sources */, D77330010000000000000004 /* RemoteTmuxMirrorMutationCoordinator.swift in Sources */, D77330010000000000000002 /* RemoteTmuxMirrorMutationSnapshot.swift in Sources */, F11ED7AB0004000400040004 /* RemoteTmuxMirrorNewTabPlacement.swift in Sources */, @@ -8508,6 +8515,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B0555301B0555301B0555301 /* RemoteTmuxControlStreamParserBudgetTests.swift in Sources */, F6724602A1B2C3D4E5F67246 /* RemoteTmuxHostRemoteCommandOverrideTests.swift in Sources */, 79380000000000000000000C /* RemoteTmuxLayoutNodePatchingTests.swift in Sources */, + CD1E0676BA61709DAE7341B4 /* RemoteTmuxLocalEndpointTests.swift in Sources */, 6732BEEF6732BEEF6732B002 /* RemoteTmuxMasterReadinessTests.swift in Sources */, 7738A0027738A0027738A002 /* RemoteTmuxMirrorCLIFailClosedTests.swift in Sources */, 7738A0017738A0017738A001 /* RemoteTmuxMirrorCLIObservabilityTests.swift in Sources */, diff --git a/cmuxTests/RemoteTmuxLocalEndpointTests.swift b/cmuxTests/RemoteTmuxLocalEndpointTests.swift new file mode 100644 index 000000000000..d31768a49bb1 --- /dev/null +++ b/cmuxTests/RemoteTmuxLocalEndpointTests.swift @@ -0,0 +1,330 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Coverage for the local tmux endpoint (`RemoteTmuxHost.local`): the identity +/// invariants that keep it from ever aliasing an SSH endpoint, the argv shapes +/// that run tmux directly, the transport's local no-SSH command path, and — when +/// a tmux binary is installed — a live `tmux -CC` control-mode attach under a +/// locally-allocated PTY against an isolated (`TMUX_TMPDIR`) server, including +/// one sync round-trip in each direction (tmux→cmux and cmux→tmux). +@MainActor +@Suite(.serialized) struct RemoteTmuxLocalEndpointTests { + // MARK: - Identity + + @Test func localHostIdentityNeverAliasesSSH() throws { + let local = RemoteTmuxHost.local + #expect(local.isLocal) + #expect(local.kind == .local) + #expect(local.connectionHash == "local") + + // An ssh alias that happens to be named `local` stays a distinct + // endpoint: its hash is a 16-hex digest, never the literal "local". + let sshAliasNamedLocal = RemoteTmuxHost(destination: "local") + #expect(!sshAliasNamedLocal.isLocal) + #expect(sshAliasNamedLocal.connectionHash != local.connectionHash) + #expect(sshAliasNamedLocal.connectionHash.count == 16) + + // No ControlMaster socket work for the local endpoint. + try RemoteTmuxHost.local.ensureControlSocketDirectory() + } + + @Test func localControlModeInvocationShapes() { + let create = RemoteTmuxHost.local.localControlModeInvocation( + sessionName: "my session", + createIfMissing: true + ) + #expect(create.first == "/bin/sh") + #expect(create.suffix(5) == ["-CC", "new-session", "-A", "-s", "my session"]) + + let attach = RemoteTmuxHost.local.localControlModeInvocation( + sessionName: "dev", + createIfMissing: false + ) + #expect(attach.first == "/bin/sh") + #expect(attach.suffix(4) == ["-CC", "attach-session", "-t", "dev"]) + } + + @Test func socketParamsBuildLocalHost() { + let local = TerminalController.remoteTmuxHost(from: ["local": true]) + #expect(local?.isLocal == true) + + // `local: true` wins over SSH params. + let both = TerminalController.remoteTmuxHost(from: ["local": true, "host": "dev@example.test"]) + #expect(both?.isLocal == true) + + // A plain string host named "local" is an SSH alias, not the local endpoint. + let alias = TerminalController.remoteTmuxHost(from: ["host": "local"]) + #expect(alias?.isLocal == false) + + // `local: false` falls through to the required-host validation. + #expect(TerminalController.remoteTmuxHost(from: ["local": false]) == nil) + } + + // MARK: - Transport (no tmux required) + + @Test func localTransportRunsCommandsDirectly() async throws { + let transport = RemoteTmuxSSHTransport(host: .local) + let result = try await transport.run(["echo", "local-ok"]) + #expect(result.succeeded) + #expect(result.stdout.contains("local-ok")) + + // The master lifecycle degenerates to a ready no-op locally. + #expect(try await transport.ensureMasterReady()) + } + + // MARK: - Live control mode (requires an installed tmux) + + /// Whether the tmux resolver finds a usable binary; the live test is a no-op + /// on machines without tmux instead of a failure. + private static func tmuxInstalled() -> Bool { + let argv = RemoteTmuxHost.tmuxLocalInvocation(arguments: ["-V"]) + let process = Process() + process.executableURL = URL(fileURLWithPath: argv[0]) + process.arguments = Array(argv.dropFirst()) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return false + } + process.waitUntilExit() + return process.terminationStatus == 0 + } + + /// Live end-to-end: create a session on an isolated local server, attach the + /// control connection under a local PTY, observe the topology arrive + /// (tmux→cmux), split from both sides, and verify each split lands. + @Test func localControlModeAttachSyncsBothWays() async throws { + guard Self.tmuxInstalled() else { return } + + // A SHORT path, deliberately not NSTemporaryDirectory(): tmux binds + // `$TMUX_TMPDIR/tmux-<uid>/default`, and the sandbox's /var/folders + // temp root plus a UUID overflows the AF_UNIX 104-byte limit + // ("File name too long"). + let root = URL( + fileURLWithPath: "/tmp/cmux-lt-\(UUID().uuidString.prefix(8))", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let previousTmpdir = getenv("TMUX_TMPDIR").map { String(cString: $0) } + setenv("TMUX_TMPDIR", root.path, 1) + // Defers run LIFO: the env restore is registered FIRST so the + // kill-server below still sees the isolated TMUX_TMPDIR — a kill that + // ran after the restore would hit the user's real tmux server. + defer { + if let previousTmpdir { + setenv("TMUX_TMPDIR", previousTmpdir, 1) + } else { + unsetenv("TMUX_TMPDIR") + } + try? FileManager.default.removeItem(at: root) + } + defer { Self.runTmuxSynchronously(["kill-server"]) } + let session = "cmux-local-e2e" + let transport = RemoteTmuxSSHTransport(host: .local) + + let created = try await transport.runTmux([ + "new-session", "-d", "-s", session, "-x", "120", "-y", "30", + ]) + try #require(created.succeeded, Comment(rawValue: created.stderr)) + + let connection = RemoteTmuxControlConnection(host: .local, sessionName: session) + defer { connection.stop() } + try connection.start() + + let connected = await withTaskGroup(of: Bool.self) { group in + group.addTask { @MainActor in await connection.waitUntilConnected() } + group.addTask { + try? await Task.sleep(for: .seconds(20)) + return false + } + let first = await group.next() ?? false + group.cancelAll() + return first + } + try #require(connected, "local tmux -CC control stream never reached %enter") + + // tmux→cmux: the initial topology publishes through the layout pipeline. + try await waitUntil("initial window topology") { + !connection.windowsByID.isEmpty + } + let windowId = try #require(connection.windowsByID.keys.first) + + // tmux→cmux: an out-of-band split (another client / plain tmux command) + // must arrive as a layout change with a second pane. + let split = try await transport.runTmux(["split-window", "-h", "-t", "@\(windowId)"]) + try #require(split.succeeded, Comment(rawValue: split.stderr)) + try await waitUntil("out-of-band split visible in mirror state") { + (connection.windowsByID[windowId]?.paneIDsInOrder.count ?? 0) == 2 + } + + // cmux→tmux: a split sent on the control stream must land on the server. + #expect(connection.send("split-window -v -t @\(windowId)")) + try await waitUntil("control-stream split visible on the server") { + (connection.windowsByID[windowId]?.paneIDsInOrder.count ?? 0) == 3 + } + let paneCount = try await transport.runTmux([ + "display-message", "-p", "-t", session + ":", "#{window_panes}", + ]) + #expect(paneCount.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "3") + } + + /// Live: creating a session anywhere on the server must reach + /// `onSessionsChanged` observers on an already-attached control connection — + /// the notification the controller's session-set reconcile (auto-mirroring + /// of new sessions) is keyed on. + @Test func sessionsChangedNotificationReachesObservers() async throws { + guard Self.tmuxInstalled() else { return } + + let root = URL( + fileURLWithPath: "/tmp/cmux-lt-\(UUID().uuidString.prefix(8))", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let previousTmpdir = getenv("TMUX_TMPDIR").map { String(cString: $0) } + setenv("TMUX_TMPDIR", root.path, 1) + // LIFO: restore the env AFTER kill-server so the kill targets the + // isolated server, never the user's real one. + defer { + if let previousTmpdir { + setenv("TMUX_TMPDIR", previousTmpdir, 1) + } else { + unsetenv("TMUX_TMPDIR") + } + try? FileManager.default.removeItem(at: root) + } + defer { Self.runTmuxSynchronously(["kill-server"]) } + let session = "cmux-local-sesschange" + let transport = RemoteTmuxSSHTransport(host: .local) + + let created = try await transport.runTmux([ + "new-session", "-d", "-s", session, "-x", "120", "-y", "30", + ]) + try #require(created.succeeded, Comment(rawValue: created.stderr)) + + let connection = RemoteTmuxControlConnection(host: .local, sessionName: session) + defer { connection.stop() } + try connection.start() + let connected = await withTaskGroup(of: Bool.self) { group in + group.addTask { @MainActor in await connection.waitUntilConnected() } + group.addTask { + try? await Task.sleep(for: .seconds(20)) + return false + } + let first = await group.next() ?? false + group.cancelAll() + return first + } + try #require(connected, "local tmux -CC control stream never reached %enter") + + var fired = false + let token = connection.addObserver(onSessionsChanged: { fired = true }) + defer { connection.removeObserver(token) } + + let sibling = try await transport.runTmux(["new-session", "-d", "-s", session + "-b"]) + try #require(sibling.succeeded, Comment(rawValue: sibling.stderr)) + try await waitUntil("%sessions-changed reaches observers") { fired } + } + + /// Live: releasing size authority sets tmux's `ignore-size` flag on cmux's + /// control client (so a co-attached real terminal drives the window size), and + /// reclaiming clears it. Asserted through `list-clients` — the server's own + /// view of the flag. + @Test func sizeAuthorityReleaseTogglesIgnoreSizeFlag() async throws { + guard Self.tmuxInstalled() else { return } + + let root = URL( + fileURLWithPath: "/tmp/cmux-lt-\(UUID().uuidString.prefix(8))", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let previousTmpdir = getenv("TMUX_TMPDIR").map { String(cString: $0) } + setenv("TMUX_TMPDIR", root.path, 1) + defer { + if let previousTmpdir { + setenv("TMUX_TMPDIR", previousTmpdir, 1) + } else { + unsetenv("TMUX_TMPDIR") + } + try? FileManager.default.removeItem(at: root) + } + defer { Self.runTmuxSynchronously(["kill-server"]) } + let session = "cmux-local-ignoresize" + let transport = RemoteTmuxSSHTransport(host: .local) + let created = try await transport.runTmux([ + "new-session", "-d", "-s", session, "-x", "120", "-y", "30", + ]) + try #require(created.succeeded, Comment(rawValue: created.stderr)) + + let connection = RemoteTmuxControlConnection(host: .local, sessionName: session) + defer { connection.stop() } + try connection.start() + let connected = await withTaskGroup(of: Bool.self) { group in + group.addTask { @MainActor in await connection.waitUntilConnected() } + group.addTask { + try? await Task.sleep(for: .seconds(20)) + return false + } + let first = await group.next() ?? false + group.cancelAll() + return first + } + try #require(connected, "local tmux -CC control stream never reached %enter") + + func anyClientIgnoresSize() async -> Bool { + let result = try? await transport.runTmux(["list-clients", "-F", "#{client_flags}"]) + return result?.stdout.contains("ignore-size") ?? false + } + func waitForIgnoreSize(_ expected: Bool, _ what: String) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(15)) + while await anyClientIgnoresSize() != expected { + if clock.now > deadline { Issue.record("timed out waiting for \(what)"); return } + try await Task.sleep(for: .milliseconds(50)) + } + } + + connection.setSizeAuthorityReleased(true) + try await waitForIgnoreSize(true, "ignore-size flag set on the control client") + + connection.setSizeAuthorityReleased(false) + try await waitForIgnoreSize(false, "ignore-size flag cleared on the control client") + } + + /// Synchronous tmux one-shot for `defer` cleanup (a `defer` cannot await, + /// and an escaped async cleanup would outlive the test's env restoration). + /// Inherits the live process environment, TMUX_TMPDIR included. + private static func runTmuxSynchronously(_ arguments: [String]) { + let argv = RemoteTmuxHost.tmuxLocalInvocation(arguments: arguments) + let process = Process() + process.executableURL = URL(fileURLWithPath: argv[0]) + process.arguments = Array(argv.dropFirst()) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return } + process.waitUntilExit() + } + + private func waitUntil( + _ what: String, + timeoutSeconds: Double = 15, + _ condition: @MainActor () -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(timeoutSeconds)) + while !condition() { + if clock.now > deadline { + Issue.record("timed out waiting for \(what)") + return + } + try await Task.sleep(for: .milliseconds(50)) + } + } +} diff --git a/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift b/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift index 3683eac5f7d6..f8f625a01ff2 100644 --- a/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift +++ b/cmuxTests/RemoteTmuxMirrorNewTabPlacementTests.swift @@ -23,7 +23,7 @@ import Testing @Test func appendsAtEndWhenNoTargetWindow() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: nil, workingDirectory: nil) - == "new-window -d -a -t '{end}'" + == "new-window -d -a -t '{end}' -c '#{pane_current_path}'" ) } @@ -32,7 +32,7 @@ import Testing @Test func insertsAfterSelectedWindow() { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: nil) - == "new-window -d -a -t @7" + == "new-window -d -a -t @7 -c '#{pane_current_path}'" ) } @@ -53,7 +53,7 @@ import Testing afterWindowId: 7, workingDirectory: nil, focus: true - ) == "new-window -P -F '#{window_id}' -a -t @7" + ) == "new-window -P -F '#{window_id}' -a -t @7 -c '#{pane_current_path}'" ) } } diff --git a/cmuxTests/RemoteTmuxNewWindowCwdTests.swift b/cmuxTests/RemoteTmuxNewWindowCwdTests.swift index 37dff8b4415b..7dcd08c5defc 100644 --- a/cmuxTests/RemoteTmuxNewWindowCwdTests.swift +++ b/cmuxTests/RemoteTmuxNewWindowCwdTests.swift @@ -19,8 +19,11 @@ import Testing /// /// These assert the produced control-mode command: a known directory adds a /// single-quoted `-c` after the placement target, and absent/blank/unsafe -/// directories leave the placement-only command so a missing cwd can never break -/// the control stream. +/// directories fall back to `-c '#{pane_current_path}'` — the target window's own +/// active-pane directory, expanded server-side — so a new tab inherits the +/// current directory instead of the session start dir (cmux's `/` process cwd for +/// a session it created). The literal `#{pane_current_path}` carries no cwd bytes, +/// so it can never break the control stream. @Suite struct RemoteTmuxNewWindowCwdTests { @Test func seedsStartingDirectoryAfterSelectedWindow() { #expect( @@ -57,10 +60,10 @@ import Testing " ", "\t", ]) - func omitsDirectoryWhenUnusable(_ directory: String?) { + func fallsBackToCurrentPanePathWhenUnusable(_ directory: String?) { #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: directory) - == "new-window -d -a -t @7" + == "new-window -d -a -t @7 -c '#{pane_current_path}'" ) } @@ -71,10 +74,11 @@ import Testing ]) func dropsDirectoriesThatCouldBreakTheControlStream(_ directory: String) { // CR/LF/control bytes could terminate the command line before tmux parses - // the quoted argument, so an unsafe path leaves the placement-only command. + // the quoted argument, so an unsafe path is treated as unknown and falls + // back to the current-pane-path format (which carries no cwd bytes). #expect( RemoteTmuxController.newWindowCommand(afterWindowId: 7, workingDirectory: directory) - == "new-window -d -a -t @7" + == "new-window -d -a -t @7 -c '#{pane_current_path}'" ) } diff --git a/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx b/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx index efee26fac29e..194853928291 100644 --- a/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx +++ b/web/app/[locale]/(landing)/docs/remote-tmux/page.tsx @@ -75,6 +75,13 @@ export default async function RemoteTmuxPage({ <CodeBlock lang="bash">{`cmux ssh-tmux dev@example.com\ncmux ssh-tmux my-ssh-alias --port 2222 --identity ~/.ssh/id_ed25519\ncmux ssh-tmux dev@example.com --new-window`}</CodeBlock> <p>{t("attachSockets")}</p> + <DocsHeading level={2} id="local-tmux">{t("localTitle")}</DocsHeading> + <p>{t("localIntro")}</p> + <CodeBlock lang="bash">{`cmux tmux\ncmux tmux --new-window`}</CodeBlock> + <p>{t("localSync")}</p> + <p>{t("localSocket")}</p> + <CodeBlock lang="json">{`{ "method": "remote.tmux.mirror", "params": { "local": true } }`}</CodeBlock> + <DocsHeading level={3} id="permission-denied">{t("troubleshootTitle")}</DocsHeading> <p>{t("troubleshootDesc")}</p> <CodeBlock lang="text">{`Host my-ssh-alias\n HostName 203.0.113.10\n User dev\n IdentityFile ~/.ssh/id_ed25519`}</CodeBlock> diff --git a/web/messages/en.json b/web/messages/en.json index 3a3b3370a781..bdce1be5b21f 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1426,6 +1426,10 @@ "attachNewWindow": "Pass --new-window to open the mirror in a dedicated new window instead of the current window.", "attachSockets": "The remote.tmux.* socket commands (below) give finer control. remote.tmux.mirror is the mirror entry point; it accepts window/caller routing and can optionally select the mirrored workspace.", "attachCli": "Hosts that authenticate non-interactively (ssh-agent, or a key in ~/.ssh/config) attach with no prompt. If the host needs interactive authentication (a password, host-key confirmation, or MFA), cmux runs ssh inline in your terminal so you can authenticate, then mirrors into the current window. Accepts --port, --identity, and --no-focus.", + "localTitle": "Local tmux", + "localIntro": "The same mirroring works for the tmux server on your own machine — no SSH involved. Run cmux tmux to mirror every local tmux session into the current window's sidebar, or pass --new-window for a dedicated window. cmux attaches a tmux control-mode client (tmux -CC) directly under a local pseudo-terminal, so it needs no daemon, no network, and no authentication.", + "localSync": "Sync is two-way, exactly like a remote mirror: splits, closes, renames, reorders, and new windows made in cmux propagate to tmux, and anything done on the tmux side — including from a plain tmux client attached to the same session in another terminal — appears in cmux immediately.", + "localSocket": "Every remote.tmux.* socket method accepts local: true in place of host to target the local server. A string host named \"local\" is still treated as an SSH alias, so an existing ~/.ssh/config alias by that name keeps working.", "troubleshootTitle": "If you get \"Permission denied\"", "troubleshootDesc": "The destination is handed to ssh as-is, so an alias must carry everything ssh needs to log in — most commonly User (without it, ssh tries your local username) and IdentityFile:", "troubleshootFallback": "Or pass the user explicitly: cmux ssh-tmux dev@my-ssh-alias. If plain ssh <destination> doesn't log you in, cmux ssh-tmux <destination> won't either.", diff --git a/web/messages/ja.json b/web/messages/ja.json index c7f8e795d1d2..f85add6659af 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1345,6 +1345,10 @@ "attachNewWindow": "--new-window を指定すると、現在のウィンドウの代わりに専用の新しいウィンドウでミラーを開きます。", "attachSockets": "remote.tmux.* のソケットコマンド(下記)でより細かく制御できます。remote.tmux.mirror がミラーリングのエントリポイントで、ウィンドウや呼び出し元のルーティングを受け取り、必要に応じてミラーワークスペースを選択できます。", "attachCli": "非対話で認証されるホスト(ssh-agent や ~/.ssh/config の鍵)はプロンプトなしで接続されます。対話的な認証(パスワード、ホストキーの確認、多要素認証)が必要なホストでは、cmux がそのターミナル内で ssh を実行するため、その場で認証でき、その後 cmux が現在のウィンドウにミラーリングします。--port、--identity、--no-focus を指定できます。", + "localTitle": "ローカル tmux", + "localIntro": "同じミラーリングは、自分のマシン上の tmux サーバーに対しても SSH なしで動作します。cmux tmux を実行すると、すべてのローカル tmux セッションが現在のウィンドウのサイドバーにミラーリングされます。--new-window を付けると専用ウィンドウで開きます。cmux は tmux コントロールモードクライアント(tmux -CC)をローカルの疑似端末上で直接アタッチするため、デーモンもネットワークも認証も不要です。", + "localSync": "同期はリモートミラーと同様に双方向です。cmux で行った分割・クローズ・名前変更・並べ替え・新規ウィンドウは tmux に反映され、tmux 側での操作(別のターミナルから同じセッションに接続している通常の tmux クライアントからの操作を含む)も即座に cmux に表示されます。", + "localSocket": "すべての remote.tmux.* ソケットメソッドは、host の代わりに local: true を受け付けてローカルサーバーを対象にできます。文字列の host に \"local\" を指定した場合は従来どおり SSH エイリアスとして扱われるため、同名の ~/.ssh/config エイリアスはそのまま機能します。", "troubleshootTitle": "「Permission denied」が出る場合", "troubleshootDesc": "宛先はそのまま ssh に渡されるため、エイリアスにはログインに必要な設定をすべて含める必要があります。特に User(未指定だと ssh はローカルのユーザー名を使います)と IdentityFile です:", "troubleshootFallback": "または cmux ssh-tmux dev@my-ssh-alias のようにユーザーを明示指定してください。素の ssh <宛先> でログインできない場合は、cmux ssh-tmux <宛先> でも接続できません。",