From 41998fe88e043a4b38a262c4d7709d6c04304417 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 04:58:29 -0700 Subject: [PATCH 01/10] test: cover OMO tmux respawn panes --- tests/test_cli_omo_tmux_respawn_pane.py | 376 ++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 tests/test_cli_omo_tmux_respawn_pane.py diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py new file mode 100644 index 000000000000..907dc8fe2759 --- /dev/null +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +""" +Regression tests for OMO subagent panes through cmux's tmux compatibility shim. +""" + +from __future__ import annotations + +import json +import os +import socketserver +import subprocess +import tempfile +import threading +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + +WORKSPACE_ID = "11111111-1111-4111-8111-111111111111" +WINDOW_ID = "22222222-2222-4222-8222-222222222222" +PANE_ID = "33333333-3333-4333-8333-333333333333" +SURFACE_ID = "44444444-4444-4444-8444-444444444444" +SUBAGENT_PANE_ID = "66666666-6666-4666-8666-666666666666" +SUBAGENT_SURFACE_ID = "77777777-7777-4777-8777-777777777777" + +PLACEHOLDER_COMMAND = ( + '/bin/sh -c "printf \\"OMO subagent pane ready: test\\\\n' + 'Focus this pane to attach.\\"; while :; do sleep 86400; done"' +) +ATTACH_COMMAND = ( + '/bin/sh -c "opencode attach http://127.0.0.1:4096 ' + '--session subagent-session --dir /tmp/omo-workspace"' +) + + +class FakeCmuxState: + def __init__(self) -> None: + self.split_created = False + self.placeholder_command: str | None = None + self.respawn_params: list[dict[str, object]] = [] + self.sent_text: list[dict[str, object]] = [] + + def handle(self, method: str, params: dict[str, object]) -> dict[str, object]: + if method == "workspace.list": + return { + "workspaces": [ + { + "id": WORKSPACE_ID, + "ref": "workspace:1", + "index": 1, + "title": "omo", + } + ] + } + if method == "window.list": + return { + "windows": [ + { + "id": WINDOW_ID, + "ref": "window:1", + "workspace_id": WORKSPACE_ID, + "workspace_ref": "workspace:1", + } + ] + } + if method == "surface.list": + surfaces = [ + { + "id": SURFACE_ID, + "ref": "surface:1", + "focused": not self.split_created, + "pane_id": PANE_ID, + "pane_ref": "pane:1", + "title": "leader", + "type": "terminal", + } + ] + if self.split_created: + surfaces.append( + { + "id": SUBAGENT_SURFACE_ID, + "ref": "surface:2", + "focused": True, + "pane_id": SUBAGENT_PANE_ID, + "pane_ref": "pane:2", + "title": "OMO subagent pane ready", + "type": "terminal", + "tmux_start_command": self.placeholder_command, + } + ) + return {"surfaces": surfaces} + if method == "surface.current": + if self.split_created: + return { + "workspace_id": WORKSPACE_ID, + "workspace_ref": "workspace:1", + "pane_id": SUBAGENT_PANE_ID, + "pane_ref": "pane:2", + "surface_id": SUBAGENT_SURFACE_ID, + "surface_ref": "surface:2", + "surface_type": "terminal", + } + return { + "workspace_id": WORKSPACE_ID, + "workspace_ref": "workspace:1", + "pane_id": PANE_ID, + "pane_ref": "pane:1", + "surface_id": SURFACE_ID, + "surface_ref": "surface:1", + "surface_type": "terminal", + } + if method == "pane.list": + panes = [ + { + "id": PANE_ID, + "ref": "pane:1", + "index": 1, + "focused": not self.split_created, + } + ] + if self.split_created: + panes.append( + { + "id": SUBAGENT_PANE_ID, + "ref": "pane:2", + "index": 2, + "focused": True, + } + ) + return {"panes": panes} + if method == "pane.surfaces": + pane_id = str(params.get("pane_id") or "") + if pane_id == PANE_ID: + return {"surfaces": [{"id": SURFACE_ID, "selected": True}]} + if pane_id == SUBAGENT_PANE_ID: + return {"surfaces": [{"id": SUBAGENT_SURFACE_ID, "selected": True}]} + raise RuntimeError(f"unknown pane: {pane_id}") + if method == "surface.split": + if params.get("surface_id") != SURFACE_ID: + raise RuntimeError(f"expected split anchor {SURFACE_ID}, got {params!r}") + self.split_created = True + start_command = params.get("tmux_start_command") + self.placeholder_command = start_command if isinstance(start_command, str) else None + return { + "workspace_id": WORKSPACE_ID, + "surface_id": SUBAGENT_SURFACE_ID, + "pane_id": SUBAGENT_PANE_ID, + } + if method == "surface.respawn": + self.respawn_params.append(dict(params)) + return { + "workspace_id": WORKSPACE_ID, + "surface_id": params.get("surface_id"), + "type": "terminal", + } + if method == "surface.send_text": + self.sent_text.append(dict(params)) + return {"ok": True} + if method == "workspace.equalize_splits": + return {"ok": True} + raise RuntimeError(f"Unsupported fake cmux method: {method}") + + +class FakeCmuxHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + while True: + line = self.rfile.readline() + if not line: + return + + request = json.loads(line.decode("utf-8")) + try: + result = self.server.state.handle( # type: ignore[attr-defined] + request["method"], + request.get("params", {}), + ) + response = {"ok": True, "result": result, "id": request.get("id")} + except Exception as exc: + response = { + "ok": False, + "error": {"code": "not_found", "message": str(exc)}, + "id": request.get("id"), + } + + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + + +class FakeCmuxUnixServer(socketserver.ThreadingUnixStreamServer): + allow_reuse_address = True + + def __init__(self, socket_path: str, state: FakeCmuxState) -> None: + self.state = state + super().__init__(socket_path, FakeCmuxHandler) + + +def run_cli( + cli_path: str, + socket_path: Path, + fake_home: Path, + args: list[str], +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = str(socket_path) + env["CMUX_WORKSPACE_ID"] = "workspace:1" + env["CMUX_SURFACE_ID"] = "surface:1" + env["TMUX_PANE"] = f"%{PANE_ID}" + env["HOME"] = str(fake_home) + env["CMUX_OMO_CMUX_BIN"] = cli_path + return subprocess.run( + [cli_path, "--socket", str(socket_path), *args], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + +def assert_success(proc: subprocess.CompletedProcess[str], label: str) -> None: + if proc.returncode != 0: + raise AssertionError( + f"{label} returned non-zero\n" + f"stdout={proc.stdout.strip()}\n" + f"stderr={proc.stderr.strip()}" + ) + + +def assert_omo_split_is_listed_and_respawned( + cli_path: str, + socket_path: Path, + fake_home: Path, + state: FakeCmuxState, +) -> None: + split = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "split-window", + "-h", + "-P", + "-F", + "#{pane_id}", + PLACEHOLDER_COMMAND, + ], + ) + assert_success(split, "OMO placeholder split") + subagent_pane_token = split.stdout.strip() + if not subagent_pane_token.startswith("%"): + raise AssertionError(f"expected tmux pane token, got {subagent_pane_token!r}") + + listed = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "list-panes", + "-F", + "#{pane_id},#{pane_active},#{window_active}", + ], + ) + assert_success(listed, "OMO list-panes") + lines = [line.strip() for line in listed.stdout.splitlines() if line.strip()] + if len(lines) != 2: + raise AssertionError(f"expected leader and subagent panes, got {lines!r}") + if f"{subagent_pane_token},1,1" not in lines: + raise AssertionError(f"expected active subagent pane in list-panes, got {lines!r}") + + respawn = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "respawn-pane", + "-k", + "-t", + subagent_pane_token, + ATTACH_COMMAND, + ], + ) + assert_success(respawn, "OMO respawn-pane") + if len(state.respawn_params) != 1: + raise AssertionError(f"expected one surface.respawn call, got {state.respawn_params!r}") + respawn_params = state.respawn_params[0] + if respawn_params.get("workspace_id") != WORKSPACE_ID: + raise AssertionError(f"respawn targeted wrong workspace: {respawn_params!r}") + if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: + raise AssertionError(f"respawn targeted wrong surface: {respawn_params!r}") + if respawn_params.get("command") != ATTACH_COMMAND: + raise AssertionError(f"respawn carried wrong command: {respawn_params!r}") + if respawn_params.get("tmux_start_command") != ATTACH_COMMAND: + raise AssertionError(f"respawn did not update tmux start metadata: {respawn_params!r}") + if state.sent_text: + raise AssertionError(f"respawn must replace the pane, not send text: {state.sent_text!r}") + + +def assert_public_respawn_uses_same_surface_lifecycle( + cli_path: str, + socket_path: Path, + fake_home: Path, + state: FakeCmuxState, +) -> None: + public = run_cli( + cli_path, + socket_path, + fake_home, + [ + "respawn-pane", + "--workspace", + "workspace:1", + "--surface", + "surface:2", + "--command", + "echo TEST_PUBLIC", + ], + ) + assert_success(public, "public respawn-pane") + if len(state.respawn_params) != 2: + raise AssertionError(f"expected public respawn to call surface.respawn: {state.respawn_params!r}") + respawn_params = state.respawn_params[-1] + if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: + raise AssertionError(f"public respawn targeted wrong surface: {respawn_params!r}") + if respawn_params.get("command") != "echo TEST_PUBLIC": + raise AssertionError(f"public respawn carried wrong command: {respawn_params!r}") + if state.sent_text: + raise AssertionError(f"public respawn must not send text: {state.sent_text!r}") + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + try: + with tempfile.TemporaryDirectory(prefix="cmux-omo-respawn-") as td: + tmp = Path(td) + socket_path = tmp / "fake-cmux.sock" + state = FakeCmuxState() + server = FakeCmuxUnixServer(str(socket_path), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + fake_home = tmp / "home" + fake_home.mkdir(parents=True, exist_ok=True) + + try: + assert_omo_split_is_listed_and_respawned( + cli_path, + socket_path, + fake_home, + state, + ) + assert_public_respawn_uses_same_surface_lifecycle( + cli_path, + socket_path, + fake_home, + state, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + except AssertionError as exc: + print(f"FAIL: {exc}") + return 1 + + print("PASS: OMO tmux shim lists and respawns subagent panes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 735b0172c873bc7b45d3dd1e763956e1db0f9f09 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 05:11:04 -0700 Subject: [PATCH 02/10] fix: respawn OMO subagent panes --- CLI/cmux.swift | 33 ++++- Sources/GhosttyTerminalView.swift | 11 +- Sources/Panels/TerminalPanel.swift | 2 + Sources/TerminalController.swift | 78 ++++++++++++ Sources/Workspace.swift | 116 ++++++++++++++++++ .../WorkspaceSplitStartupCommandTests.swift | 38 ++++++ 6 files changed, 274 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f3560fa6e6a9..c598c06898e9 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -20187,6 +20187,32 @@ struct CMUXCLI { "orientation": "vertical" ]) + case "respawn-pane", "respawnp": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-t"], + boolFlags: ["-k"] + ) + let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) + let surfaceId = try tmuxSelectedSurfaceId( + workspaceId: target.workspaceId, + paneId: target.paneId, + client: client + ) + let commandText = tmuxStartCommand(commandTokens: parsed.positional) + ?? "exec ${SHELL:-/bin/sh} -l" + var params: [String: Any] = [ + "workspace_id": target.workspaceId, + "surface_id": surfaceId, + "command": commandText, + "tmux_start_command": commandText + ] + if let cwd = parsed.value("-c")?.trimmingCharacters(in: .whitespacesAndNewlines), + !cwd.isEmpty { + params["working_directory"] = resolvePath(cwd) + } + _ = try client.sendV2(method: "surface.respawn", params: params) + case "send-keys", "send": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"]) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) @@ -21120,14 +21146,17 @@ struct CMUXCLI { let workspaceArg = workspaceOpt ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let commandText = (commandOpt ?? respawnRem3.dropFirst(respawnRem3.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText - var params: [String: Any] = ["text": finalCommand + "\n"] + var params: [String: Any] = [ + "command": finalCommand, + "tmux_start_command": finalCommand + ] let winId = try normalizeWindowHandle(effectiveWindowRaw, client: client) if let winId { params["window_id"] = winId } let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId, allowCurrent: winId == nil) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId, allowFocused: true) if let sfId { params["surface_id"] = sfId } - let payload = try client.sendV2(method: "surface.send_text", params: params) + let payload = try client.sendV2(method: "surface.respawn", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "display-message": diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3ab921d593ba..1b3de3abe924 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5127,8 +5127,14 @@ final class TerminalSurfaceRegistry { func unregister(_ surface: TerminalSurface) { lock.lock() + let surfaceId = surface.id surfaces.remove(surface) - surfaceFocusPlacements.removeValue(forKey: surface.id) + let stillRegistered = surfaces.allObjects + .compactMap { $0 as? TerminalSurface } + .contains { $0 !== surface && $0.id == surfaceId } + if !stillRegistered { + surfaceFocusPlacements.removeValue(forKey: surfaceId) + } lock.unlock() Task { @MainActor in @@ -5483,6 +5489,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } init( + id: UUID = UUID(), tabId: UUID, context: ghostty_surface_context_e, configTemplate: CmuxSurfaceConfigTemplate?, @@ -5499,7 +5506,7 @@ final class TerminalSurface: Identifiable, ObservableObject { dispatchPrecondition(condition: .onQueue(.main)) #endif - self.id = UUID() + self.id = id self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 1694a4319e98..eb2b8848c1c6 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -155,6 +155,7 @@ final class TerminalPanel: Panel, ObservableObject { /// Create a new terminal panel with a fresh surface convenience init( + id: UUID = UUID(), workspaceId: UUID, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: CmuxSurfaceConfigTemplate? = nil, @@ -168,6 +169,7 @@ final class TerminalPanel: Panel, ObservableObject { focusPlacement: TerminalSurfaceFocusPlacement = .workspace ) { let surface = TerminalSurface( + id: id, tabId: workspaceId, context: context, configTemplate: configTemplate, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7d26f163029a..1a35fbb48b04 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2845,6 +2845,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceFocus(params: params)) case "surface.split": return v2Result(id: id, self.v2SurfaceSplit(params: params)) + case "surface.respawn": + return v2Result(id: id, self.v2SurfaceRespawn(params: params)) case "surface.create": return v2Result(id: id, self.v2SurfaceCreate(params: params)) case "surface.close": @@ -3312,6 +3314,7 @@ class TerminalController { "surface.current", "surface.focus", "surface.split", + "surface.respawn", "surface.create", "surface.close", "surface.drag_to_split", @@ -8672,6 +8675,81 @@ class TerminalController { } return result } + + private func v2SurfaceRespawn(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let command = v2OptionalTrimmedRawString(params, "command") + ?? v2OptionalTrimmedRawString(params, "initial_command") + ?? "exec ${SHELL:-/bin/zsh} -l" + let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") ?? command + let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") + let focus: Bool? + if v2HasNonNullParam(params, "focus") { + guard let parsedFocus = v2Bool(params, "focus") else { + return .err(code: "invalid_params", message: "Missing or invalid focus", data: nil) + } + focus = v2FocusAllowed(requested: parsedFocus) + } else { + focus = nil + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to respawn surface", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let surfaceId: UUID? + if params["surface_id"] != nil { + surfaceId = v2UUID(params, "surface_id") + guard surfaceId != nil else { + result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) + return + } + } else { + surfaceId = ws.focusedPanelId + } + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard ws.terminalPanel(for: surfaceId) != nil else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + guard let replacementPanel = ws.respawnTerminalSurface( + panelId: surfaceId, + command: command, + workingDirectory: workingDirectory, + tmuxStartCommand: tmuxStartCommand, + focus: focus + ) else { + result = .err(code: "internal_error", message: "Failed to respawn surface", data: ["surface_id": surfaceId.uuidString]) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "type": replacementPanel.panelType.rawValue, + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + private func v2SurfaceCreate(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ff6a73dc6b33..9672706cdf9e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14632,6 +14632,122 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } + /// Replace the terminal process behind an existing surface while preserving its pane and tab identity. + @discardableResult + func respawnTerminalSurface( + panelId: UUID, + command: String, + workingDirectory: String? = nil, + tmuxStartCommand: String? = nil, + focus: Bool? = nil + ) -> TerminalPanel? { + guard let oldPanel = terminalPanel(for: panelId), + let tabId = surfaceIdFromPanelId(panelId), + let paneId = paneId(forPanelId: panelId) else { + return nil + } + + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCommand.isEmpty else { return nil } + + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let requestedWorkingDirectory: String? = { + if let workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !workingDirectory.isEmpty { + return workingDirectory + } + if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelDirectory.isEmpty { + return panelDirectory + } + if let requestedWorkingDirectory = oldPanel.requestedWorkingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !requestedWorkingDirectory.isEmpty { + return requestedWorkingDirectory + } + let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return workspaceDirectory.isEmpty ? nil : workspaceDirectory + }() + let selectedInPane = bonsplitController.selectedTab(inPane: paneId)?.id == tabId + let paneWasFocused = bonsplitController.focusedPaneId == paneId + let shouldFocus = focus ?? (selectedInPane && paneWasFocused) + let customTitle = panelCustomTitles[panelId] + let wasPinned = pinnedPanelIds.contains(panelId) + let startCommand = tmuxStartCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + let replacementTmuxStartCommand = (startCommand?.isEmpty == false) ? startCommand : trimmedCommand + let focusPlacement = oldPanel.surface.focusPlacement + + discardClosedPanelLifecycleState( + panelId: panelId, + tabId: tabId, + paneId: paneId, + panel: oldPanel, + origin: "terminal_respawn", + closePanel: true, + publishSurfaceClosedEvent: false, + clearSurfaceNotifications: true, + requestTransferredRemoteCleanup: true, + cleanupControllerSurfaceState: true + ) + TerminalSurfaceRegistry.shared.unregister(oldPanel.surface) + + let replacementPanel = TerminalPanel( + id: panelId, + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: requestedWorkingDirectory, + portOrdinal: portOrdinal, + initialCommand: trimmedCommand, + tmuxStartCommand: replacementTmuxStartCommand, + focusPlacement: focusPlacement + ) + configureNewTerminalPanel(replacementPanel) + panels[panelId] = replacementPanel + panelTitles[panelId] = replacementPanel.displayTitle + if let customTitle { + panelCustomTitles[panelId] = customTitle + } + if wasPinned { + pinnedPanelIds.insert(panelId) + } + surfaceIdToPanelId[tabId] = panelId + seedTerminalInheritanceFontPoints(panelId: panelId, configTemplate: inheritedConfig) + + let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: replacementPanel.displayTitle) + bonsplitController.updateTab( + tabId, + title: resolvedTitle, + icon: .some(replacementPanel.displayIcon), + iconImageData: .some(nil), + kind: .some(SurfaceKind.terminal), + hasCustomTitle: customTitle != nil, + isDirty: replacementPanel.isDirty, + showsNotificationBadge: false, + isLoading: false, + isPinned: wasPinned + ) + + if shouldFocus { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(tabId) + focusPanel(panelId) + } else if selectedInPane { + bonsplitController.selectTab(tabId) + applyTabSelection(tabId: tabId, inPane: paneId) + } else { + replacementPanel.unfocus() + } + + owningTabManager?.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: id, + panelId: panelId, + reason: "terminalRespawn" + ) + scheduleTerminalGeometryReconcile() + scheduleFocusReconcile() + return replacementPanel + } + private func remoteTerminalStartupCommand() -> String? { guard !suppressRemoteTerminalStartupForSessionRestoreScaffold else { return nil diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 8ff4ffc7d1dd..3129e08ecf57 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -232,6 +232,44 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(surface.surface.debugTmuxStartCommand(), tmuxStartCommand) } + func testRespawnTerminalSurfacePreservesPaneTabAndSurfaceIdentity() throws { + let workspace = Workspace() + let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) + let placeholderCommand = "/bin/sh -c 'printf placeholder; while :; do sleep 86400; done'" + let attachCommand = "/bin/sh -c 'opencode attach http://127.0.0.1:4096 --session subagent --dir /tmp/omo'" + let requestedDirectory = "/tmp/cmux-respawn-\(UUID().uuidString)" + + let placeholderPanel = try XCTUnwrap(workspace.newTerminalSplit( + from: sourcePanelId, + orientation: .horizontal, + focus: true, + initialCommand: placeholderCommand, + tmuxStartCommand: placeholderCommand + )) + let originalPanelId = placeholderPanel.id + let originalPaneId = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelId)?.id) + let originalTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(originalPanelId)) + + let respawnedPanel = try XCTUnwrap(workspace.respawnTerminalSurface( + panelId: originalPanelId, + command: attachCommand, + workingDirectory: requestedDirectory, + tmuxStartCommand: attachCommand + )) + + XCTAssertEqual(respawnedPanel.id, originalPanelId) + XCTAssertTrue(workspace.terminalPanel(for: originalPanelId) === respawnedPanel) + XCTAssertEqual(workspace.paneId(forPanelId: originalPanelId)?.id, originalPaneId) + XCTAssertEqual(workspace.surfaceIdFromPanelId(originalPanelId), originalTabId) + XCTAssertEqual(respawnedPanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) + XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) + XCTAssertTrue( + TerminalSurfaceRegistry.shared.surface(id: originalPanelId) === respawnedPanel.surface, + "Respawn should replace the registered terminal surface for the existing cmux surface id" + ) + } + func testSessionRestoreRelaunchesOMXHudTmuxStartCommand() throws { let workspace = Workspace() let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) From ef3fb4b845ac16bbbf22c7a7b0ef4f227f986b77 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 05:16:59 -0700 Subject: [PATCH 03/10] fix: align tmux respawn pane semantics --- CLI/cmux.swift | 33 ++++++-- Sources/GhosttyTerminalViewSupport.swift | 4 + Sources/Workspace.swift | 5 +- .../WorkspaceSplitStartupCommandTests.swift | 1 + tests/test_cli_omo_tmux_respawn_pane.py | 80 ++++++++++++++++++- 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c598c06898e9..fcaafeaded94 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17288,6 +17288,25 @@ struct CMUXCLI { throw CLIError(message: "Pane has no surface to target") } + private func tmuxStoredStartCommand( + workspaceId: String, + surfaceId: String, + client: SocketClient + ) throws -> String? { + let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + guard let surface = surfaces.first(where: { ($0["id"] as? String) == surfaceId }) else { + return nil + } + return [ + surface["tmux_start_command"], + surface["pane_start_command"], + surface["initial_command"] + ] + .compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + } + private func tmuxResolveSurfaceTarget( _ raw: String?, client: SocketClient @@ -20193,17 +20212,17 @@ struct CMUXCLI { valueFlags: ["-c", "-t"], boolFlags: ["-k"] ) - let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) - let surfaceId = try tmuxSelectedSurfaceId( - workspaceId: target.workspaceId, - paneId: target.paneId, - client: client - ) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) let commandText = tmuxStartCommand(commandTokens: parsed.positional) + ?? (try? tmuxStoredStartCommand( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId, + client: client + )) ?? "exec ${SHELL:-/bin/sh} -l" var params: [String: Any] = [ "workspace_id": target.workspaceId, - "surface_id": surfaceId, + "surface_id": target.surfaceId, "command": commandText, "tmux_start_command": commandText ] diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 085d34d1d24a..9dd9e0f3a2f5 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -39,6 +39,10 @@ extension TerminalSurface { tmuxStartCommand } + func debugWaitAfterCommand() -> Bool { + configTemplate?.waitAfterCommand ?? false + } + func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) { let byteCount = initialInput?.utf8.count ?? 0 return (byteCount > 0, byteCount) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9672706cdf9e..5d65a4b25eb7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14650,7 +14650,7 @@ final class Workspace: Identifiable, ObservableObject { let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedCommand.isEmpty else { return nil } - let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + var inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) let requestedWorkingDirectory: String? = { if let workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !workingDirectory.isEmpty { @@ -14675,6 +14675,9 @@ final class Workspace: Identifiable, ObservableObject { let startCommand = tmuxStartCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let replacementTmuxStartCommand = (startCommand?.isEmpty == false) ? startCommand : trimmedCommand let focusPlacement = oldPanel.surface.focusPlacement + var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() + template.waitAfterCommand = true + inheritedConfig = template discardClosedPanelLifecycleState( panelId: panelId, diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 3129e08ecf57..bb8c0636c198 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -264,6 +264,7 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(respawnedPanel.requestedWorkingDirectory, requestedDirectory) XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) + XCTAssertTrue(respawnedPanel.surface.debugWaitAfterCommand()) XCTAssertTrue( TerminalSurfaceRegistry.shared.surface(id: originalPanelId) === respawnedPanel.surface, "Respawn should replace the registered terminal surface for the existing cmux surface id" diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 907dc8fe2759..b88e7d3b7316 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -19,6 +19,7 @@ WINDOW_ID = "22222222-2222-4222-8222-222222222222" PANE_ID = "33333333-3333-4333-8333-333333333333" SURFACE_ID = "44444444-4444-4444-8444-444444444444" +SELECTED_SOURCE_SURFACE_ID = "55555555-5555-4555-8555-555555555555" SUBAGENT_PANE_ID = "66666666-6666-4666-8666-666666666666" SUBAGENT_SURFACE_ID = "77777777-7777-4777-8777-777777777777" @@ -72,6 +73,17 @@ def handle(self, method: str, params: dict[str, object]) -> dict[str, object]: "pane_ref": "pane:1", "title": "leader", "type": "terminal", + "tmux_start_command": "echo CALLER_STORED", + }, + { + "id": SELECTED_SOURCE_SURFACE_ID, + "ref": "surface:3", + "focused": False, + "pane_id": PANE_ID, + "pane_ref": "pane:1", + "title": "selected source tab", + "type": "terminal", + "tmux_start_command": "echo SELECTED_STORED", } ] if self.split_created: @@ -130,7 +142,12 @@ def handle(self, method: str, params: dict[str, object]) -> dict[str, object]: if method == "pane.surfaces": pane_id = str(params.get("pane_id") or "") if pane_id == PANE_ID: - return {"surfaces": [{"id": SURFACE_ID, "selected": True}]} + return { + "surfaces": [ + {"id": SELECTED_SOURCE_SURFACE_ID, "selected": True}, + {"id": SURFACE_ID, "selected": False}, + ] + } if pane_id == SUBAGENT_PANE_ID: return {"surfaces": [{"id": SUBAGENT_SURFACE_ID, "selected": True}]} raise RuntimeError(f"unknown pane: {pane_id}") @@ -268,6 +285,30 @@ def assert_omo_split_is_listed_and_respawned( if f"{subagent_pane_token},1,1" not in lines: raise AssertionError(f"expected active subagent pane in list-panes, got {lines!r}") + empty_respawn = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "respawn-pane", + "-k", + "-t", + subagent_pane_token, + ], + ) + assert_success(empty_respawn, "OMO empty respawn-pane") + if len(state.respawn_params) != 1: + raise AssertionError(f"expected empty respawn to call surface.respawn: {state.respawn_params!r}") + empty_respawn_params = state.respawn_params[0] + if empty_respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: + raise AssertionError(f"empty respawn targeted wrong surface: {empty_respawn_params!r}") + if empty_respawn_params.get("command") != PLACEHOLDER_COMMAND: + raise AssertionError(f"empty respawn did not reuse stored command: {empty_respawn_params!r}") + if empty_respawn_params.get("tmux_start_command") != PLACEHOLDER_COMMAND: + raise AssertionError(f"empty respawn did not preserve tmux start metadata: {empty_respawn_params!r}") + state.respawn_params.clear() + respawn = run_cli( cli_path, socket_path, @@ -297,6 +338,37 @@ def assert_omo_split_is_listed_and_respawned( raise AssertionError(f"respawn must replace the pane, not send text: {state.sent_text!r}") +def assert_caller_pane_respawn_uses_caller_surface( + cli_path: str, + socket_path: Path, + fake_home: Path, + state: FakeCmuxState, +) -> None: + respawn = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "respawn-pane", + "-k", + "-t", + f"%{PANE_ID}", + ATTACH_COMMAND, + ], + ) + assert_success(respawn, "caller pane respawn-pane") + if len(state.respawn_params) != 1: + raise AssertionError(f"expected caller pane respawn to call surface.respawn: {state.respawn_params!r}") + respawn_params = state.respawn_params[0] + if respawn_params.get("surface_id") != SURFACE_ID: + raise AssertionError( + "caller pane respawn should prefer CMUX_SURFACE_ID over the pane's selected tab: " + f"{respawn_params!r}" + ) + state.respawn_params.clear() + + def assert_public_respawn_uses_same_surface_lifecycle( cli_path: str, socket_path: Path, @@ -348,6 +420,12 @@ def main() -> int: fake_home.mkdir(parents=True, exist_ok=True) try: + assert_caller_pane_respawn_uses_caller_surface( + cli_path, + socket_path, + fake_home, + state, + ) assert_omo_split_is_listed_and_respawned( cli_path, socket_path, From b2213610caee187fac2e83acc9977ff341264ebb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 08:18:57 -0700 Subject: [PATCH 04/10] fix: expose terminal wait-after debug state --- Sources/GhosttyTerminalView.swift | 4 ++++ Sources/GhosttyTerminalViewSupport.swift | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1b3de3abe924..7e41f9471614 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5548,6 +5548,10 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + func debugWaitAfterCommand() -> Bool { + configTemplate?.waitAfterCommand ?? false + } + func updateWorkspaceId(_ newTabId: UUID) { tabId = newTabId attachedView?.tabId = newTabId diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 9dd9e0f3a2f5..085d34d1d24a 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -39,10 +39,6 @@ extension TerminalSurface { tmuxStartCommand } - func debugWaitAfterCommand() -> Bool { - configTemplate?.waitAfterCommand ?? false - } - func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) { let byteCount = initialInput?.utf8.count ?? 0 return (byteCount > 0, byteCount) From 7cac9d1ff3b22e61d7108f55eb71274541b54d2d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 13:13:46 -0700 Subject: [PATCH 05/10] fix: keep respawned tmux panes attached --- CLI/cmux.swift | 13 ++++++++++--- Sources/GhosttyTerminalView.swift | 17 +++++++++++++++++ Sources/Workspace.swift | 8 +++++++- .../WorkspaceSplitStartupCommandTests.swift | 9 ++++++++- tests/test_cli_omo_tmux_respawn_pane.py | 1 + 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fcaafeaded94..9e507f108af7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21171,10 +21171,17 @@ struct CMUXCLI { ] let winId = try normalizeWindowHandle(effectiveWindowRaw, client: client) if let winId { params["window_id"] = winId } - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId, allowCurrent: winId == nil) + let wsHandle = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId, allowCurrent: winId == nil) + let wsId = try wsHandle.map { try resolveWorkspaceId($0, client: client, windowHandle: winId) } if let wsId { params["workspace_id"] = wsId } - let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId, allowFocused: true) - if let sfId { params["surface_id"] = sfId } + let sfHandle = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId, allowFocused: true) + if let sfHandle { + if let wsId { + params["surface_id"] = try resolveSurfaceId(sfHandle, workspaceId: wsId, client: client) + } else { + params["surface_id"] = sfHandle + } + } let payload = try client.sendV2(method: "surface.respawn", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7e41f9471614..864802213067 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2186,6 +2186,23 @@ class GhosttyApp { DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } + guard let callbackSurface = callbackContext.terminalSurface else { +#if DEBUG + cmuxDebugLog( + "surface.closeCallback.ignore surface=\(callbackSurfaceId.uuidString.prefix(5)) reason=missingCallbackSurface" + ) +#endif + return + } + if let registeredSurface = TerminalSurfaceRegistry.shared.surface(id: callbackSurfaceId), + registeredSurface !== callbackSurface { +#if DEBUG + cmuxDebugLog( + "surface.closeCallback.ignore surface=\(callbackSurfaceId.uuidString.prefix(5)) reason=staleCallbackSurface" + ) +#endif + return + } // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. if let callbackTabId, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5d65a4b25eb7..ae6ff6412309 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14679,19 +14679,25 @@ final class Workspace: Identifiable, ObservableObject { template.waitAfterCommand = true inheritedConfig = template + oldPanel.unfocus() + oldPanel.hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.detach(hostedView: oldPanel.hostedView) + oldPanel.surface.beginPortalCloseLifecycle(reason: "terminal.respawn") + discardClosedPanelLifecycleState( panelId: panelId, tabId: tabId, paneId: paneId, panel: oldPanel, origin: "terminal_respawn", - closePanel: true, + closePanel: false, publishSurfaceClosedEvent: false, clearSurfaceNotifications: true, requestTransferredRemoteCleanup: true, cleanupControllerSurfaceState: true ) TerminalSurfaceRegistry.shared.unregister(oldPanel.surface) + oldPanel.surface.teardownSurface() let replacementPanel = TerminalPanel( id: panelId, diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index bb8c0636c198..1097a631258c 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -247,8 +247,11 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { tmuxStartCommand: placeholderCommand )) let originalPanelId = placeholderPanel.id - let originalPaneId = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelId)?.id) + let originalPane = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelId)) + let originalPaneId = originalPane.id let originalTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(originalPanelId)) + let originalPaneCount = workspace.bonsplitController.allPaneIds.count + let originalTabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let respawnedPanel = try XCTUnwrap(workspace.respawnTerminalSurface( panelId: originalPanelId, @@ -261,6 +264,10 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertTrue(workspace.terminalPanel(for: originalPanelId) === respawnedPanel) XCTAssertEqual(workspace.paneId(forPanelId: originalPanelId)?.id, originalPaneId) XCTAssertEqual(workspace.surfaceIdFromPanelId(originalPanelId), originalTabId) + XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, originalPaneCount) + XCTAssertTrue(workspace.bonsplitController.allPaneIds.contains(where: { $0.id == originalPaneId })) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: originalPane).count, originalTabCount) + XCTAssertTrue(workspace.bonsplitController.tabs(inPane: originalPane).contains(where: { $0.id == originalTabId })) XCTAssertEqual(respawnedPanel.requestedWorkingDirectory, requestedDirectory) XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index b88e7d3b7316..3b0d24d79ff9 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -308,6 +308,7 @@ def assert_omo_split_is_listed_and_respawned( if empty_respawn_params.get("tmux_start_command") != PLACEHOLDER_COMMAND: raise AssertionError(f"empty respawn did not preserve tmux start metadata: {empty_respawn_params!r}") state.respawn_params.clear() + state.sent_text.clear() respawn = run_cli( cli_path, From e09b32bd9d524200eed16c212842e8034c2c36ef Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 13:25:50 -0700 Subject: [PATCH 06/10] fix: tighten tmux respawn semantics --- CLI/cmux.swift | 17 ++- Resources/Localizable.xcstrings | 136 ++++++++++++++++++ Sources/GhosttyTerminalView.swift | 4 + Sources/TerminalController.swift | 115 ++++++++++++--- Sources/Workspace.swift | 8 +- .../WorkspaceSplitStartupCommandTests.swift | 3 +- tests/test_cli_omo_tmux_respawn_pane.py | 28 ++++ 7 files changed, 280 insertions(+), 31 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9e507f108af7..1367bb0b3614 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -20212,14 +20212,23 @@ struct CMUXCLI { valueFlags: ["-c", "-t"], boolFlags: ["-k"] ) + guard parsed.hasFlag("-k") else { + throw CLIError(message: String( + localized: "cli.tmuxCompat.respawnPane.requiresForce", + defaultValue: "respawn-pane requires -k in cmux tmux compatibility mode" + )) + } let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) - let commandText = tmuxStartCommand(commandTokens: parsed.positional) - ?? (try? tmuxStoredStartCommand( + let commandText: String + if let explicitCommand = tmuxStartCommand(commandTokens: parsed.positional) { + commandText = explicitCommand + } else { + commandText = try tmuxStoredStartCommand( workspaceId: target.workspaceId, surfaceId: target.surfaceId, client: client - )) - ?? "exec ${SHELL:-/bin/sh} -l" + ) ?? "exec ${SHELL:-/bin/sh} -l" + } var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 799eeff637f4..b94d8074b9c6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13653,6 +13653,142 @@ } } }, + "cli.tmuxCompat.respawnPane.requiresForce": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "respawn-pane requires -k in cmux tmux compatibility mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux の tmux 互換モードでは respawn-pane に -k が必要です" + } + } + } + }, + "rpc.v2.surface.respawn.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to respawn surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを再起動できませんでした" + } + } + } + }, + "rpc.v2.surface.respawn.invalidFocus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid focus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "focus が指定されていないか無効です" + } + } + } + }, + "rpc.v2.surface.respawn.noFocusedSurface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No focused surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカス中のサーフェスがありません" + } + } + } + }, + "rpc.v2.surface.respawn.surfaceNotFoundForId": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface not found for the given surface_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指定された surface_id のサーフェスが見つかりません" + } + } + } + }, + "rpc.v2.surface.respawn.surfaceNotTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface is not a terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスはターミナルではありません" + } + } + } + }, + "rpc.v2.surface.respawn.tabManagerUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "TabManager not available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TabManager を利用できません" + } + } + } + }, + "rpc.v2.surface.respawn.workspaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースが見つかりません" + } + } + } + }, "socket.notification.dismissSelectorRequired": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 864802213067..c43110a0f5bc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5569,6 +5569,10 @@ final class TerminalSurface: Identifiable, ObservableObject { configTemplate?.waitAfterCommand ?? false } + var launchContext: ghostty_surface_context_e { + surfaceContext + } + func updateWorkspaceId(_ newTabId: UUID) { tabId = newTabId attachedView?.tabId = newTabId diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1a35fbb48b04..4fdf164ca4e2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8677,8 +8677,15 @@ class TerminalController { } private func v2SurfaceRespawn(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + guard let fallbackTabManager = v2ResolveTabManager(params: params) else { + return .err( + code: "unavailable", + message: String( + localized: "rpc.v2.surface.respawn.tabManagerUnavailable", + defaultValue: "TabManager not available" + ), + data: nil + ) } let command = v2OptionalTrimmedRawString(params, "command") @@ -8689,36 +8696,95 @@ class TerminalController { let focus: Bool? if v2HasNonNullParam(params, "focus") { guard let parsedFocus = v2Bool(params, "focus") else { - return .err(code: "invalid_params", message: "Missing or invalid focus", data: nil) + return .err( + code: "invalid_params", + message: String( + localized: "rpc.v2.surface.respawn.invalidFocus", + defaultValue: "Missing or invalid focus" + ), + data: nil + ) } focus = v2FocusAllowed(requested: parsedFocus) } else { focus = nil } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to respawn surface", data: nil) + var result: V2CallResult = .err( + code: "internal_error", + message: String( + localized: "rpc.v2.surface.respawn.failed", + defaultValue: "Failed to respawn surface" + ), + data: nil + ) v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return - } - - let surfaceId: UUID? - if params["surface_id"] != nil { - surfaceId = v2UUID(params, "surface_id") - guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) + let ws: Workspace + let tabManager: TabManager + let surfaceId: UUID + if v2HasNonNullParam(params, "surface_id") { + guard let requestedSurfaceId = v2UUID(params, "surface_id") else { + result = .err( + code: "not_found", + message: String( + localized: "rpc.v2.surface.respawn.surfaceNotFoundForId", + defaultValue: "Surface not found for the given surface_id" + ), + data: nil + ) return } + guard let located = AppDelegate.shared?.locateSurface(surfaceId: requestedSurfaceId), + let locatedWorkspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }) else { + result = .err( + code: "not_found", + message: String( + localized: "rpc.v2.surface.respawn.surfaceNotFoundForId", + defaultValue: "Surface not found for the given surface_id" + ), + data: ["surface_id": requestedSurfaceId.uuidString] + ) + return + } + ws = locatedWorkspace + tabManager = located.tabManager + surfaceId = requestedSurfaceId } else { - surfaceId = ws.focusedPanelId - } - guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + guard let resolvedWorkspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager) else { + result = .err( + code: "not_found", + message: String( + localized: "rpc.v2.surface.respawn.workspaceNotFound", + defaultValue: "Workspace not found" + ), + data: nil + ) + return + } + guard let focusedSurfaceId = resolvedWorkspace.focusedPanelId else { + result = .err( + code: "not_found", + message: String( + localized: "rpc.v2.surface.respawn.noFocusedSurface", + defaultValue: "No focused surface" + ), + data: nil + ) + return + } + ws = resolvedWorkspace + tabManager = fallbackTabManager + surfaceId = focusedSurfaceId } guard ws.terminalPanel(for: surfaceId) != nil else { - result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + result = .err( + code: "invalid_params", + message: String( + localized: "rpc.v2.surface.respawn.surfaceNotTerminal", + defaultValue: "Surface is not a terminal" + ), + data: ["surface_id": surfaceId.uuidString] + ) return } @@ -8732,7 +8798,14 @@ class TerminalController { tmuxStartCommand: tmuxStartCommand, focus: focus ) else { - result = .err(code: "internal_error", message: "Failed to respawn surface", data: ["surface_id": surfaceId.uuidString]) + result = .err( + code: "internal_error", + message: String( + localized: "rpc.v2.surface.respawn.failed", + defaultValue: "Failed to respawn surface" + ), + data: ["surface_id": surfaceId.uuidString] + ) return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ae6ff6412309..d43c83ab5220 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14675,9 +14675,7 @@ final class Workspace: Identifiable, ObservableObject { let startCommand = tmuxStartCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let replacementTmuxStartCommand = (startCommand?.isEmpty == false) ? startCommand : trimmedCommand let focusPlacement = oldPanel.surface.focusPlacement - var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - inheritedConfig = template + let launchContext = oldPanel.surface.launchContext oldPanel.unfocus() oldPanel.hostedView.setVisibleInUI(false) @@ -14694,7 +14692,7 @@ final class Workspace: Identifiable, ObservableObject { publishSurfaceClosedEvent: false, clearSurfaceNotifications: true, requestTransferredRemoteCleanup: true, - cleanupControllerSurfaceState: true + cleanupControllerSurfaceState: false ) TerminalSurfaceRegistry.shared.unregister(oldPanel.surface) oldPanel.surface.teardownSurface() @@ -14702,7 +14700,7 @@ final class Workspace: Identifiable, ObservableObject { let replacementPanel = TerminalPanel( id: panelId, workspaceId: id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + context: launchContext, configTemplate: inheritedConfig, workingDirectory: requestedWorkingDirectory, portOrdinal: portOrdinal, diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 1097a631258c..01bc77201778 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -252,6 +252,7 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { let originalTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(originalPanelId)) let originalPaneCount = workspace.bonsplitController.allPaneIds.count let originalTabCount = workspace.bonsplitController.tabs(inPane: originalPane).count + let originalWaitAfterCommand = placeholderPanel.surface.debugWaitAfterCommand() let respawnedPanel = try XCTUnwrap(workspace.respawnTerminalSurface( panelId: originalPanelId, @@ -271,7 +272,7 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(respawnedPanel.requestedWorkingDirectory, requestedDirectory) XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) - XCTAssertTrue(respawnedPanel.surface.debugWaitAfterCommand()) + XCTAssertEqual(respawnedPanel.surface.debugWaitAfterCommand(), originalWaitAfterCommand) XCTAssertTrue( TerminalSurfaceRegistry.shared.surface(id: originalPanelId) === respawnedPanel.surface, "Respawn should replace the registered terminal surface for the existing cmux surface id" diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 3b0d24d79ff9..dfd714deb67c 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -242,6 +242,18 @@ def assert_success(proc: subprocess.CompletedProcess[str], label: str) -> None: ) +def assert_failure(proc: subprocess.CompletedProcess[str], label: str, expected: str) -> None: + if proc.returncode == 0: + raise AssertionError(f"{label} unexpectedly succeeded\nstdout={proc.stdout.strip()}") + combined = f"{proc.stdout}\n{proc.stderr}" + if expected not in combined: + raise AssertionError( + f"{label} did not include {expected!r}\n" + f"stdout={proc.stdout.strip()}\n" + f"stderr={proc.stderr.strip()}" + ) + + def assert_omo_split_is_listed_and_respawned( cli_path: str, socket_path: Path, @@ -285,6 +297,22 @@ def assert_omo_split_is_listed_and_respawned( if f"{subagent_pane_token},1,1" not in lines: raise AssertionError(f"expected active subagent pane in list-panes, got {lines!r}") + non_forced_respawn = run_cli( + cli_path, + socket_path, + fake_home, + [ + "__tmux-compat", + "respawn-pane", + "-t", + subagent_pane_token, + ATTACH_COMMAND, + ], + ) + assert_failure(non_forced_respawn, "OMO non-forced respawn-pane", "requires -k") + if state.respawn_params: + raise AssertionError(f"non-forced respawn must not call surface.respawn: {state.respawn_params!r}") + empty_respawn = run_cli( cli_path, socket_path, From d514d2d3c6dff4d5822acc8932028af03df24257 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 13:26:45 -0700 Subject: [PATCH 07/10] test: isolate omo respawn assertions --- cmuxTests/WorkspaceSplitStartupCommandTests.swift | 7 ++++--- tests/test_cli_omo_tmux_respawn_pane.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 01bc77201778..4e01185eee41 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -263,12 +263,13 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(respawnedPanel.id, originalPanelId) XCTAssertTrue(workspace.terminalPanel(for: originalPanelId) === respawnedPanel) - XCTAssertEqual(workspace.paneId(forPanelId: originalPanelId)?.id, originalPaneId) + let currentPane = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelId)) + XCTAssertEqual(currentPane.id, originalPaneId) XCTAssertEqual(workspace.surfaceIdFromPanelId(originalPanelId), originalTabId) XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, originalPaneCount) XCTAssertTrue(workspace.bonsplitController.allPaneIds.contains(where: { $0.id == originalPaneId })) - XCTAssertEqual(workspace.bonsplitController.tabs(inPane: originalPane).count, originalTabCount) - XCTAssertTrue(workspace.bonsplitController.tabs(inPane: originalPane).contains(where: { $0.id == originalTabId })) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: currentPane).count, originalTabCount) + XCTAssertTrue(workspace.bonsplitController.tabs(inPane: currentPane).contains(where: { $0.id == originalTabId })) XCTAssertEqual(respawnedPanel.requestedWorkingDirectory, requestedDirectory) XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index dfd714deb67c..1586b1b7e42b 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -312,6 +312,7 @@ def assert_omo_split_is_listed_and_respawned( assert_failure(non_forced_respawn, "OMO non-forced respawn-pane", "requires -k") if state.respawn_params: raise AssertionError(f"non-forced respawn must not call surface.respawn: {state.respawn_params!r}") + state.sent_text.clear() empty_respawn = run_cli( cli_path, @@ -335,6 +336,8 @@ def assert_omo_split_is_listed_and_respawned( raise AssertionError(f"empty respawn did not reuse stored command: {empty_respawn_params!r}") if empty_respawn_params.get("tmux_start_command") != PLACEHOLDER_COMMAND: raise AssertionError(f"empty respawn did not preserve tmux start metadata: {empty_respawn_params!r}") + if state.sent_text: + raise AssertionError(f"empty respawn must replace the pane, not send text: {state.sent_text!r}") state.respawn_params.clear() state.sent_text.clear() @@ -404,6 +407,9 @@ def assert_public_respawn_uses_same_surface_lifecycle( fake_home: Path, state: FakeCmuxState, ) -> None: + state.respawn_params.clear() + state.sent_text.clear() + public = run_cli( cli_path, socket_path, @@ -419,9 +425,9 @@ def assert_public_respawn_uses_same_surface_lifecycle( ], ) assert_success(public, "public respawn-pane") - if len(state.respawn_params) != 2: + if len(state.respawn_params) != 1: raise AssertionError(f"expected public respawn to call surface.respawn: {state.respawn_params!r}") - respawn_params = state.respawn_params[-1] + respawn_params = state.respawn_params[0] if respawn_params.get("surface_id") != SUBAGENT_SURFACE_ID: raise AssertionError(f"public respawn targeted wrong surface: {respawn_params!r}") if respawn_params.get("command") != "echo TEST_PUBLIC": From 4ad8d8ffaa34240d47368efd1df8882fe38d2337 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 13:34:28 -0700 Subject: [PATCH 08/10] fix: clear respawn warning budget --- Sources/Workspace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d43c83ab5220..efa6c3f02076 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14650,7 +14650,7 @@ final class Workspace: Identifiable, ObservableObject { let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedCommand.isEmpty else { return nil } - var inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) let requestedWorkingDirectory: String? = { if let workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !workingDirectory.isEmpty { From 228c266786a53064c9aa1363cac776cb0405fbf3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 14:04:27 -0700 Subject: [PATCH 09/10] fix: preserve respawn startup environment --- Sources/GhosttyTerminalView.swift | 8 ++++++++ Sources/Workspace.swift | 4 ++++ cmuxTests/WorkspaceSplitStartupCommandTests.swift | 10 +++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c43110a0f5bc..aebb1479e9d8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5361,6 +5361,14 @@ final class TerminalSurface: Identifiable, ObservableObject { var requestedWorkingDirectory: String? { workingDirectory } let focusPlacement: TerminalSurfaceFocusPlacement private var additionalEnvironment: [String: String] + var respawnInitialEnvironmentOverrides: [String: String] { + initialEnvironmentOverrides + } + var respawnAdditionalEnvironment: [String: String] { + var environment = additionalEnvironment + environment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey) + return environment + } let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index efa6c3f02076..83af8d8a6681 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14676,6 +14676,8 @@ final class Workspace: Identifiable, ObservableObject { let replacementTmuxStartCommand = (startCommand?.isEmpty == false) ? startCommand : trimmedCommand let focusPlacement = oldPanel.surface.focusPlacement let launchContext = oldPanel.surface.launchContext + let initialEnvironmentOverrides = oldPanel.surface.respawnInitialEnvironmentOverrides + let additionalEnvironment = oldPanel.surface.respawnAdditionalEnvironment oldPanel.unfocus() oldPanel.hostedView.setVisibleInUI(false) @@ -14706,6 +14708,8 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: trimmedCommand, tmuxStartCommand: replacementTmuxStartCommand, + initialEnvironmentOverrides: initialEnvironmentOverrides, + additionalEnvironment: additionalEnvironment, focusPlacement: focusPlacement ) configureNewTerminalPanel(replacementPanel) diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 4e01185eee41..6302aa9b05b5 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -238,13 +238,18 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { let placeholderCommand = "/bin/sh -c 'printf placeholder; while :; do sleep 86400; done'" let attachCommand = "/bin/sh -c 'opencode attach http://127.0.0.1:4096 --session subagent --dir /tmp/omo'" let requestedDirectory = "/tmp/cmux-respawn-\(UUID().uuidString)" + let startupEnvironment = [ + "CMUX_OMO_SUBAGENT": "1", + "OMO_SUBAGENT_DESC": "test" + ] let placeholderPanel = try XCTUnwrap(workspace.newTerminalSplit( from: sourcePanelId, orientation: .horizontal, focus: true, initialCommand: placeholderCommand, - tmuxStartCommand: placeholderCommand + tmuxStartCommand: placeholderCommand, + startupEnvironment: startupEnvironment )) let originalPanelId = placeholderPanel.id let originalPane = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelId)) @@ -274,6 +279,9 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(respawnedPanel.surface.debugInitialCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugTmuxStartCommand(), attachCommand) XCTAssertEqual(respawnedPanel.surface.debugWaitAfterCommand(), originalWaitAfterCommand) + for (key, value) in startupEnvironment { + XCTAssertEqual(respawnedPanel.surface.startupEnvironmentValue(key), value) + } XCTAssertTrue( TerminalSurfaceRegistry.shared.surface(id: originalPanelId) === respawnedPanel.surface, "Respawn should replace the registered terminal surface for the existing cmux surface id" From 10c8a3f8de50ce07def0880acdeefcc308844f6f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 5 Jun 2026 14:38:14 -0700 Subject: [PATCH 10/10] fix: address omo respawn review feedback --- Resources/Localizable.xcstrings | 4 ++-- Sources/TerminalController.swift | 22 ++++++++++++---------- Sources/Workspace.swift | 2 +- tests/test_cli_omo_tmux_respawn_pane.py | 4 ++++ 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b94d8074b9c6..79d681c798b3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13761,13 +13761,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "TabManager not available" + "value": "Unable to access the target workspace" } }, "ja": { "stringUnit": { "state": "translated", - "value": "TabManager を利用できません" + "value": "対象のワークスペースにアクセスできません" } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4fdf164ca4e2..37f612471813 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8677,16 +8677,7 @@ class TerminalController { } private func v2SurfaceRespawn(params: [String: Any]) -> V2CallResult { - guard let fallbackTabManager = v2ResolveTabManager(params: params) else { - return .err( - code: "unavailable", - message: String( - localized: "rpc.v2.surface.respawn.tabManagerUnavailable", - defaultValue: "TabManager not available" - ), - data: nil - ) - } + let fallbackTabManager = v2ResolveTabManager(params: params) let command = v2OptionalTrimmedRawString(params, "command") ?? v2OptionalTrimmedRawString(params, "initial_command") @@ -8750,6 +8741,17 @@ class TerminalController { tabManager = located.tabManager surfaceId = requestedSurfaceId } else { + guard let fallbackTabManager = fallbackTabManager else { + result = .err( + code: "unavailable", + message: String( + localized: "rpc.v2.surface.respawn.tabManagerUnavailable", + defaultValue: "Unable to access the target workspace" + ), + data: nil + ) + return + } guard let resolvedWorkspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager) else { result = .err( code: "not_found", diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 83af8d8a6681..c6a1d365e6a3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14692,7 +14692,7 @@ final class Workspace: Identifiable, ObservableObject { origin: "terminal_respawn", closePanel: false, publishSurfaceClosedEvent: false, - clearSurfaceNotifications: true, + clearSurfaceNotifications: false, requestTransferredRemoteCleanup: true, cleanupControllerSurfaceState: false ) diff --git a/tests/test_cli_omo_tmux_respawn_pane.py b/tests/test_cli_omo_tmux_respawn_pane.py index 1586b1b7e42b..302204403951 100644 --- a/tests/test_cli_omo_tmux_respawn_pane.py +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -297,6 +297,8 @@ def assert_omo_split_is_listed_and_respawned( if f"{subagent_pane_token},1,1" not in lines: raise AssertionError(f"expected active subagent pane in list-panes, got {lines!r}") + state.sent_text.clear() + non_forced_respawn = run_cli( cli_path, socket_path, @@ -312,6 +314,8 @@ def assert_omo_split_is_listed_and_respawned( assert_failure(non_forced_respawn, "OMO non-forced respawn-pane", "requires -k") if state.respawn_params: raise AssertionError(f"non-forced respawn must not call surface.respawn: {state.respawn_params!r}") + if state.sent_text: + raise AssertionError(f"non-forced respawn must not send text: {state.sent_text!r}") state.sent_text.clear() empty_respawn = run_cli(