diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f3560fa6e6a9..1367bb0b3614 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 @@ -20187,6 +20206,41 @@ struct CMUXCLI { "orientation": "vertical" ]) + case "respawn-pane", "respawnp": + let parsed = try parseTmuxArguments( + rawArgs, + 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: 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" + } + var params: [String: Any] = [ + "workspace_id": target.workspaceId, + "surface_id": target.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 +21174,24 @@ 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) + 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 payload = try client.sendV2(method: "surface.send_text", params: params) + 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") case "display-message": diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 799eeff637f4..79d681c798b3 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": "Unable to access the target workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象のワークスペースにアクセスできません" + } + } + } + }, + "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 3ab921d593ba..aebb1479e9d8 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, @@ -5127,8 +5144,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 @@ -5338,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 @@ -5483,6 +5514,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } init( + id: UUID = UUID(), tabId: UUID, context: ghostty_surface_context_e, configTemplate: CmuxSurfaceConfigTemplate?, @@ -5499,7 +5531,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 @@ -5541,6 +5573,14 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + func debugWaitAfterCommand() -> Bool { + configTemplate?.waitAfterCommand ?? false + } + + var launchContext: ghostty_surface_context_e { + surfaceContext + } + func updateWorkspaceId(_ newTabId: UUID) { tabId = newTabId attachedView?.tabId = newTabId 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..37f612471813 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,156 @@ class TerminalController { } return result } + + private func v2SurfaceRespawn(params: [String: Any]) -> V2CallResult { + let fallbackTabManager = v2ResolveTabManager(params: params) + + 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: 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: String( + localized: "rpc.v2.surface.respawn.failed", + defaultValue: "Failed to respawn surface" + ), + data: nil + ) + v2MainSync { + 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 { + 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", + 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: String( + localized: "rpc.v2.surface.respawn.surfaceNotTerminal", + defaultValue: "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: String( + localized: "rpc.v2.surface.respawn.failed", + defaultValue: "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..c6a1d365e6a3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14632,6 +14632,133 @@ 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 + let launchContext = oldPanel.surface.launchContext + let initialEnvironmentOverrides = oldPanel.surface.respawnInitialEnvironmentOverrides + let additionalEnvironment = oldPanel.surface.respawnAdditionalEnvironment + + 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: false, + publishSurfaceClosedEvent: false, + clearSurfaceNotifications: false, + requestTransferredRemoteCleanup: true, + cleanupControllerSurfaceState: false + ) + TerminalSurfaceRegistry.shared.unregister(oldPanel.surface) + oldPanel.surface.teardownSurface() + + let replacementPanel = TerminalPanel( + id: panelId, + workspaceId: id, + context: launchContext, + configTemplate: inheritedConfig, + workingDirectory: requestedWorkingDirectory, + portOrdinal: portOrdinal, + initialCommand: trimmedCommand, + tmuxStartCommand: replacementTmuxStartCommand, + initialEnvironmentOverrides: initialEnvironmentOverrides, + additionalEnvironment: additionalEnvironment, + 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..6302aa9b05b5 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -232,6 +232,62 @@ 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 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, + startupEnvironment: startupEnvironment + )) + let originalPanelId = placeholderPanel.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 originalWaitAfterCommand = placeholderPanel.surface.debugWaitAfterCommand() + + let respawnedPanel = try XCTUnwrap(workspace.respawnTerminalSurface( + panelId: originalPanelId, + command: attachCommand, + workingDirectory: requestedDirectory, + tmuxStartCommand: attachCommand + )) + + XCTAssertEqual(respawnedPanel.id, originalPanelId) + XCTAssertTrue(workspace.terminalPanel(for: originalPanelId) === respawnedPanel) + 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: 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) + 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" + ) + } + func testSessionRestoreRelaunchesOMXHudTmuxStartCommand() throws { let workspace = Workspace() let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) 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..302204403951 --- /dev/null +++ b/tests/test_cli_omo_tmux_respawn_pane.py @@ -0,0 +1,493 @@ +#!/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" +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" + +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", + "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: + 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": 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}") + 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_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, + 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}") + + state.sent_text.clear() + + 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}") + 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( + 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}") + 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() + + 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_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, + fake_home: Path, + state: FakeCmuxState, +) -> None: + state.respawn_params.clear() + state.sent_text.clear() + + 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) != 1: + raise AssertionError(f"expected public respawn to call surface.respawn: {state.respawn_params!r}") + 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": + 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_caller_pane_respawn_uses_caller_surface( + cli_path, + socket_path, + fake_home, + state, + ) + 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())