diff --git a/supacode-cli/Commands/TabCommand.swift b/supacode-cli/Commands/TabCommand.swift index a87879805..2642d1c14 100644 --- a/supacode-cli/Commands/TabCommand.swift +++ b/supacode-cli/Commands/TabCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import CryptoKit import Foundation struct TabCommand: ParsableCommand { @@ -9,6 +10,7 @@ struct TabCommand: ParsableCommand { List.self, Focus.self, New.self, + AdoptZmx.self, Close.self, ], defaultSubcommand: Focus.self @@ -90,6 +92,71 @@ extension TabCommand { } } + struct AdoptZmx: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "adopt-zmx", + abstract: "Add or move an existing zmx session into a worktree tab." + ) + + @Argument(help: "Existing zmx session name.") + var session: String + + @Option(name: [.short, .long], help: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID.") + var worktree: String? + + @Option(help: "Tab title to show in Supacode.") + var title: String? + + @Option(name: [.short, .customLong("id")], help: "Stable UUID for the adopted tab.") + var newID: String? + + @OptionGroup var timeoutOption: TimeoutOption + + func run() throws { + let sessionID = try ZmxSessionArgument.normalized(session) + let worktreeID = try resolveWorktreeID(worktree) + let resolvedID: String + if let newID { + resolvedID = try Self.validatedTabID(newID) + } else { + resolvedID = Self.stableTabID(sessionID: sessionID) + } + try Dispatcher.dispatch( + deeplinkURL: DeeplinkURLBuilder.tabAdoptZmx( + worktreeID: worktreeID, + sessionID: sessionID, + title: title, + id: resolvedID + ), + timeoutSeconds: timeoutOption.timeout + ) + print(resolvedID) + } + + private static func stableTabID(sessionID: String) -> String { + let digest = SHA256.hash(data: Data("supacode:zmx-session:\(sessionID)".utf8)) + var bytes = Array(digest.prefix(16)) + bytes[6] = (bytes[6] & 0x0f) | 0x50 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return UUID( + uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], + bytes[6], bytes[7], + bytes[8], bytes[9], + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ) + ).uuidString + } + + private static func validatedTabID(_ raw: String) throws -> String { + guard let uuid = UUID(uuidString: raw) else { + throw ValidationError("Invalid --id value: expected a UUID.") + } + return uuid.uuidString + } + } + struct Close: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Close a tab.") @@ -111,3 +178,22 @@ extension TabCommand { } } } + +private nonisolated enum ZmxSessionArgument { + static func normalized(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ValidationError("zmx session name cannot be empty.") + } + guard trimmed != ".", trimmed != "..", + !trimmed.unicodeScalars.contains(where: isRejectedScalar) + else { + throw ValidationError("zmx session name cannot be '.', '..', contain '/', or contain control characters.") + } + return trimmed + } + + private static func isRejectedScalar(_ scalar: Unicode.Scalar) -> Bool { + scalar.value == 0 || scalar.value == 0x2F || CharacterSet.controlCharacters.contains(scalar) + } +} diff --git a/supacode-cli/Helpers/DeeplinkURLBuilder.swift b/supacode-cli/Helpers/DeeplinkURLBuilder.swift index c7efd73f9..79b022e41 100644 --- a/supacode-cli/Helpers/DeeplinkURLBuilder.swift +++ b/supacode-cli/Helpers/DeeplinkURLBuilder.swift @@ -52,6 +52,17 @@ nonisolated enum DeeplinkURLBuilder { return url } + static func tabAdoptZmx(worktreeID: String, sessionID: String, title: String?, id: String) -> String { + var url = "supacode://worktree/\(worktreeID)/tab/adopt-zmx" + var params = [ + "session=\(percentEncodeQueryValue(sessionID))", + "id=\(id)", + ] + if let title { params.append("title=\(percentEncodeQueryValue(title))") } + url += "?\(params.joined(separator: "&"))" + return url + } + static func tabClose(worktreeID: String, tabID: String) -> String { "supacode://worktree/\(worktreeID)/tab/\(tabID)/destroy" } diff --git a/supacode/Clients/Deeplink/DeeplinkClient.swift b/supacode/Clients/Deeplink/DeeplinkClient.swift index 40d6b7641..4c3b35986 100644 --- a/supacode/Clients/Deeplink/DeeplinkClient.swift +++ b/supacode/Clients/Deeplink/DeeplinkClient.swift @@ -187,6 +187,7 @@ private nonisolated enum DeeplinkParser { ) -> Deeplink? { // "tab/" → focus tab. // "tab/new" → create new tab. + // "tab/adopt-zmx" → ensure an existing zmx session has a tab. // "tab//destroy" → close tab. // "tab//surface/" → focus surface. // "tab//surface//split" → split surface. @@ -202,6 +203,20 @@ private nonisolated enum DeeplinkParser { let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:)) return .worktree(id: worktreeID, action: .tabNew(input: input, id: id)) } + if thirdSegment == "adopt-zmx" { + guard let rawSessionID = queryItems.first(where: { $0.name == "session" })?.value, + let sessionID = ZmxExternalSessionName.normalized(rawSessionID) + else { + logger.warning("adopt-zmx deeplink missing or invalid session.") + return nil + } + guard let id = queryItems.first(where: { $0.name == "id" })?.value.flatMap(UUID.init(uuidString:)) else { + logger.warning("adopt-zmx deeplink missing or invalid id.") + return nil + } + let title = queryItems.first(where: { $0.name == "title" })?.value + return .worktree(id: worktreeID, action: .tabAdoptZmx(sessionID: sessionID, title: title, id: id)) + } guard let tabUUID = UUID(uuidString: thirdSegment) else { logger.warning("Invalid tab UUID: \(thirdSegment)") diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index f6b379e03..8f09c6634 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -35,6 +35,7 @@ struct TerminalClient { enum Command: Equatable { case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil) case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil) + case adoptZmxSession(Worktree, sessionID: String, title: String?, id: UUID) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case stopRunScript(Worktree) case stopScript(Worktree, definitionID: UUID) diff --git a/supacode/Clients/Zmx/ZmxClient.swift b/supacode/Clients/Zmx/ZmxClient.swift index 32fbf7f68..38775644b 100644 --- a/supacode/Clients/Zmx/ZmxClient.swift +++ b/supacode/Clients/Zmx/ZmxClient.swift @@ -315,6 +315,20 @@ nonisolated enum ZmxSessionID { } } +nonisolated enum ZmxExternalSessionName { + static func normalized(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard trimmed != ".", trimmed != ".." else { return nil } + guard !trimmed.unicodeScalars.contains(where: isRejectedScalar) else { return nil } + return trimmed + } + + private static func isRejectedScalar(_ scalar: Unicode.Scalar) -> Bool { + scalar.value == 0 || scalar.value == 0x2F || CharacterSet.controlCharacters.contains(scalar) + } +} + nonisolated enum ZmxSocketBudget { /// macOS `sockaddr_un.sun_path` limit, minus a small safety margin. static let sunPathLimit = 104 @@ -388,6 +402,24 @@ nonisolated enum ZmxAttach { [executablePath, "attach", sessionID] } + static func buildExternalAttachCommand(executablePath: String, sessionID: String) -> String? { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil } + return existingExternalAttachScript(executable: shellQuote(executablePath), sessionID: sessionID) + } + + static func buildRemoteExternalAttachCommand(host: RemoteHost, sessionID: String) -> String? { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil } + let remote = posixShellWrapped(existingExternalAttachScript(executable: "zmx", sessionID: sessionID)) + return SSHCommand.commandLine(host: host, remoteCommand: remote) + } + + private static func existingExternalAttachScript(executable: String, sessionID: String) -> String { + let quotedSessionID = shellQuote(sessionID) + return "if \(executable) list --short | grep -Fx -- \(quotedSessionID) >/dev/null; " + + "then exec \(executable) attach \(quotedSessionID); " + + "else printf 'zmx session not found: %s\\n' \(quotedSessionID); exit 1; fi" + } + /// Resolves how a surface launches under zmx, given the budget-gated executable /// path (nil when zmx is unbundled or over budget). Interactive surfaces /// (`command == nil`) keep a nil command and get an argv `command-wrapper`, so diff --git a/supacode/Domain/Deeplink.swift b/supacode/Domain/Deeplink.swift index 7d6f4432f..382e08b6a 100644 --- a/supacode/Domain/Deeplink.swift +++ b/supacode/Domain/Deeplink.swift @@ -34,6 +34,7 @@ enum Deeplink: Equatable, Sendable { case appearance(title: String?, color: String?) case tab(tabID: UUID) case tabNew(input: String?, id: UUID?) + case tabAdoptZmx(sessionID: String, title: String?, id: UUID) case tabDestroy(tabID: UUID) case surface(tabID: UUID, surfaceID: UUID, input: String?) case surfaceSplit(tabID: UUID, surfaceID: UUID, direction: SplitDirection, input: String?, id: UUID?) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 8b7d52894..ef4014508 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1853,7 +1853,7 @@ struct AppFeature { // user can actually clear the orphan. let spawnsShell: Bool switch action { - case .run, .runScript, .tabNew, .surfaceSplit: + case .run, .runScript, .tabNew, .tabAdoptZmx, .surfaceSplit: spawnsShell = true case .surface(_, _, let input): spawnsShell = input?.isEmpty == false @@ -2008,6 +2008,29 @@ struct AppFeature { return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, responseFD: responseFD, timeoutSeconds: timeoutSeconds, state: &state) + case .tabAdoptZmx(let sessionID, let title, let id): + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { + state.alert = AlertState { + TextState("Invalid zmx session") + } actions: { + ButtonState(role: .cancel, action: .dismiss) { TextState("OK") } + } message: { + TextState("The zmx session name contains unsupported characters.") + } + return .none + } + let alreadyExists = terminalClient.tabExists(worktreeID, TerminalTabID(rawValue: id)) + let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in + .adoptZmxSession(worktree, sessionID: sessionID, title: title, id: id) + } + guard !alreadyExists else { return effect } + return awaitingCompletion( + effect, + match: .tabInWorktree(worktreeID: worktreeID, tabID: id), + responseFD: responseFD, + timeoutSeconds: timeoutSeconds, + state: &state + ) case .tabDestroy(let tabID): guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return .none } guard bypassConfirmation else { diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index f7338564d..a23e28e80 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -274,6 +274,8 @@ final class WorktreeTerminalManager { Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, tabID: id) } + case .adoptZmxSession(let worktree, let sessionID, let title, let id): + adoptZmxSession(in: worktree, sessionID: sessionID, title: title, tabID: id) case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) @@ -363,7 +365,7 @@ final class WorktreeTerminalManager { state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("end_search") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .setImagePasteAgents, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, @@ -381,7 +383,7 @@ final class WorktreeTerminalManager { state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) case .setImagePasteAgents(let surfaceID, let agents): setImagePasteAgents(agents, onSurfaceID: surfaceID) - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, @@ -419,7 +421,7 @@ final class WorktreeTerminalManager { // event fires; refresh here or the window keeps the previous tint. refreshFocusedSurfaceBackground() terminalLogger.info("Selected worktree \(id?.rawValue ?? "nil")") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .createTabWithInput, .adoptZmxSession, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .performBindingActionOnSurface, .setImagePasteAgents, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, @@ -607,6 +609,29 @@ final class WorktreeTerminalManager { worktreeID: worktree.id, attemptedID: tabID, message: "Could not create the tab.")) } + private func adoptZmxSession( + in worktree: Worktree, + sessionID: String, + title: String?, + tabID: UUID + ) { + let terminalTabID = TerminalTabID(rawValue: tabID) + for (existingWorktreeID, existingState) in states where existingWorktreeID != worktree.id { + guard existingState.hasTab(terminalTabID) else { continue } + existingState.closeTab(terminalTabID) + break + } + let state = state(for: worktree) + let adopted = state.adoptZmxSession(sessionID: sessionID, title: title, tabID: tabID) + guard adopted == nil else { return } + emit( + .surfaceCreationFailed( + worktreeID: worktree.id, + attemptedID: tabID, + message: "Could not adopt the zmx session." + )) + } + @discardableResult func closeFocusedTab(in worktree: Worktree) -> Bool { let state = state(for: worktree) @@ -632,7 +657,7 @@ final class WorktreeTerminalManager { } let prunedSurfaceIDs = Set(removed.flatMap { _, state in state.allSurfaceIDs }) let prunedSessionIDs = removed.flatMap { _, state in - state.allSurfaceIDs.map { ZmxSessionID.make(surfaceID: $0) } + state.ownedZmxSessionIDs(forSurfaceIDs: state.allSurfaceIDs) } let prunedRemoteSessions = Self.remoteSessions(in: removed.map(\.1)) for (id, state) in removed { @@ -659,16 +684,16 @@ final class WorktreeTerminalManager { killZmxSessions(prunedSessionIDs, remoteSessions: prunedRemoteSessions) } - /// Host-side zmx sessions owned by the given states, one entry per surface - /// of each remote worktree. Unconditional on the persistence toggle: a host - /// session may exist from an earlier launch, and the kill invocation is a - /// silent no-op when nothing exists. + /// Host-side zmx sessions owned by the given states, one entry per owned + /// surface of each remote worktree. Unconditional on the persistence toggle: + /// a host session may exist from an earlier launch, and the kill invocation + /// is a silent no-op when nothing exists. private static func remoteSessions( in states: [WorktreeTerminalState] ) -> [(host: RemoteHost, sessionID: String)] { states.flatMap { state -> [(host: RemoteHost, sessionID: String)] in guard let host = state.remoteHost else { return [] } - return state.allSurfaceIDs.map { (host, ZmxSessionID.make(surfaceID: $0)) } + return state.ownedZmxSessionIDs(forSurfaceIDs: state.allSurfaceIDs).map { (host, $0) } } } diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index 2546946d0..3d734ce77 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -74,7 +74,9 @@ final class TerminalTabManager { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } guard !tabs[index].isTitleLocked else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - tabs[index].customTitle = trimmed.isEmpty ? nil : trimmed + let customTitle = trimmed.isEmpty ? nil : trimmed + guard tabs[index].customTitle != customTitle else { return } + tabs[index].customTitle = customTitle } func isBlockingScript(_ id: TerminalTabID) -> Bool { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 0d1056153..3a26209e3 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -53,6 +53,7 @@ final class WorktreeTerminalState { private struct SurfaceLaunchMetadata { let usesZmx: Bool let context: ghostty_surface_context_e + let externalZmxSessionID: String? } let tabManager: TerminalTabManager @@ -286,6 +287,13 @@ final class WorktreeTerminalState { _ = createTab(focusing: focusing, setupScript: setupScript) } + private func restorePendingLayoutIfNeeded(focusing: Bool) { + guard let snapshot = pendingLayoutSnapshot else { return } + pendingLayoutSnapshot = nil + hasAttemptedInitialTab = true + restoreFromSnapshot(snapshot, focusing: focusing) + } + @discardableResult func createTab( focusing: Bool = true, @@ -336,6 +344,53 @@ final class WorktreeTerminalState { return tabId } + @discardableResult + func adoptZmxSession( + sessionID: String, + title: String?, + tabID requestedTabID: UUID, + focusing: Bool = true + ) -> TerminalTabID? { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID), + let command = externalZmxAttachCommand(sessionID: sessionID) + else { return nil } + restorePendingLayoutIfNeeded(focusing: false) + let terminalTabID = TerminalTabID(rawValue: requestedTabID) + if hasTab(terminalTabID) { + if externalZmxSessionID(in: terminalTabID) == sessionID { + if let title { tabManager.setCustomTitle(terminalTabID, title: title) } + // Re-applying the same external session is a sync no-op; do not steal focus. + return terminalTabID + } + closeTab(terminalTabID) + } + let resolvedTitle: String + if let trimmedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedTitle.isEmpty { + resolvedTitle = trimmedTitle + } else { + resolvedTitle = sessionID + } + let tabId = createTab( + TabCreation( + title: resolvedTitle, + icon: nil, + isTitleLocked: false, + command: command, + initialInput: nil, + focusing: focusing, + inheritingFromSurfaceId: currentFocusedSurfaceId(), + context: tabManager.tabs.isEmpty ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB, + tabID: requestedTabID, + bypassZmx: true, + externalZmxSessionID: sessionID, + ) + ) + if let tabId, let title { + tabManager.setCustomTitle(tabId, title: title) + } + return tabId + } + /// Stops a single user-defined script identified by its definition ID. @discardableResult func stopScript(definitionID: UUID) -> Bool { @@ -471,6 +526,7 @@ final class WorktreeTerminalState { /// Skip zmx session wrapping for transactional surfaces (blocking setup/archive/delete scripts) /// that must die with the app rather than survive. var bypassZmx: Bool = false + var externalZmxSessionID: String? = nil } private func createTab(_ creation: TabCreation) -> TerminalTabID? { @@ -496,7 +552,8 @@ final class WorktreeTerminalState { initialInput: creation.initialInput, context: creation.context, surfaceID: creation.tabID != nil ? tabId.rawValue : nil, - bypassZmx: creation.bypassZmx + bypassZmx: creation.bypassZmx, + externalZmxSessionID: creation.externalZmxSessionID, ) updateShouldHideTabBar() if creation.focusing, let surface = tree.root?.leftmostLeaf() { @@ -506,6 +563,14 @@ final class WorktreeTerminalState { return tabId } + private func externalZmxAttachCommand(sessionID: String) -> String? { + if let host = worktree.host { + return ZmxAttach.buildRemoteExternalAttachCommand(host: host, sessionID: sessionID) + } + guard let executablePath = zmxClient.executableURL()?.path(percentEncoded: false) else { return nil } + return ZmxAttach.buildExternalAttachCommand(executablePath: executablePath, sessionID: sessionID) + } + func listSurfaces(tabID: TerminalTabID) -> [[String: String]] { let focusedID = focusedSurfaceIdByTab[tabID] return surfaces.compactMap { surfaceID, _ in @@ -516,6 +581,21 @@ final class WorktreeTerminalState { }.sorted { ($0["id"] ?? "") < ($1["id"] ?? "") } } + private func externalZmxSessionID(in tabID: TerminalTabID) -> String? { + for surfaceID in surfaceIDs(inTab: tabID) { + if let sessionID = surfaceLaunchMetadata[surfaceID]?.externalZmxSessionID { + return sessionID + } + } + return nil + } + + private func tabContainsExternalZmxSession(_ tabID: TerminalTabID) -> Bool { + surfaceIDs(inTab: tabID).contains { surfaceID in + surfaceLaunchMetadata[surfaceID]?.externalZmxSessionID != nil + } + } + func hasTab(_ tabId: TerminalTabID) -> Bool { tabManager.tabs.contains(where: { $0.id == tabId }) } @@ -531,6 +611,14 @@ final class WorktreeTerminalState { trees.values.flatMap { $0.leaves().map(\.id) } } + /// zmx session IDs whose lifecycle is owned by Supacode for these surfaces. + func ownedZmxSessionIDs(forSurfaceIDs surfaceIDs: [UUID]) -> [String] { + surfaceIDs.compactMap { surfaceID -> String? in + guard surfaceLaunchMetadata[surfaceID]?.externalZmxSessionID == nil else { return nil } + return ZmxSessionID.make(surfaceID: surfaceID) + } + } + /// Host of a remote worktree, nil for local. Every surface in this state /// shares it, so teardown paths can target the host-side zmx sessions. var remoteHost: RemoteHost? { @@ -796,7 +884,8 @@ final class WorktreeTerminalState { initialInput: String? = nil, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, surfaceID: UUID? = nil, - bypassZmx: Bool = false + bypassZmx: Bool = false, + externalZmxSessionID: String? = nil ) -> SplitTree { if let existing = trees[tabId] { return existing @@ -808,7 +897,8 @@ final class WorktreeTerminalState { inheritingFromSurfaceId: inheritingFromSurfaceId, context: context, surfaceID: surfaceID, - bypassZmx: bypassZmx + bypassZmx: bypassZmx, + externalZmxSessionID: externalZmxSessionID ) let tree = SplitTree(view: surface) setTree(tree, for: tabId) @@ -1081,6 +1171,9 @@ final class WorktreeTerminalState { for tab in tabManager.tabs { // Blocking-script tabs die with the app; persisting them would resurrect a dead session. if tab.isBlockingScript { continue } + // Externally managed zmx sessions must be re-adopted by their owner; persisting + // them as normal tabs would restore a shell that is no longer attached. + if tabContainsExternalZmxSession(tab.id) { continue } guard let tree = trees[tab.id], let root = tree.root else { layoutLogger.warning("Skipping tab \(tab.id.rawValue) during snapshot capture (no tree)") continue @@ -1481,6 +1574,7 @@ final class WorktreeTerminalState { context: ghostty_surface_context_e, surfaceID: UUID? = nil, bypassZmx: Bool = false, + externalZmxSessionID: String? = nil, replacingExistingSurfaceID: Bool = false, ) -> GhosttySurfaceView { let resolvedID: UUID @@ -1526,7 +1620,11 @@ final class WorktreeTerminalState { ) wireSurfaceCallbacks(view: view, tabId: tabId) surfaces[view.id] = view - surfaceLaunchMetadata[view.id] = SurfaceLaunchMetadata(usesZmx: launch.usesZmx, context: context) + surfaceLaunchMetadata[view.id] = SurfaceLaunchMetadata( + usesZmx: launch.usesZmx, + context: context, + externalZmxSessionID: externalZmxSessionID + ) surfaceStates[view.id] = WorktreeSurfaceState() return view } @@ -2060,9 +2158,8 @@ final class WorktreeTerminalState { } /// Detaches one surface from the local bookkeeping. The zmx session is NOT - /// killed here; callers route the kill through `killZmxSessions(forSurfaceIDs:)` - /// so a single multi-pane close emits one `count=N` analytics event + one - /// `withTaskGroup` instead of N events and N detached Tasks. + /// killed here; callers compute the Supacode-owned session IDs before this + /// drops launch metadata, then kill the batch afterwards. /// Also cancels any held agent OSC 9 and forgets the last-custom-notification /// instant so a future surface ID can't reuse stale dedupe state. private func discardSurfaceBookkeeping(for surfaceID: UUID) { @@ -2079,23 +2176,20 @@ final class WorktreeTerminalState { onSurfacesClosed?([surfaceID]) } - /// Tears down persistent zmx sessions for surfaces the user just closed. + /// Tears down persistent zmx sessions the user just closed. /// `isBundled` (not `executableURL`) is the gate so sessions created on a /// previous under-budget launch still tear down when this launch exceeds the /// socket budget. One analytics event + one `withTaskGroup` per call. - /// `includeRemote` also tears down the host-side sessions of a remote - /// worktree; only explicit close paths set it, so a non-explicit end (clean - /// remote exit, deliberate host-side detach, or a reconnect abort) spares - /// the host session. The remote kill is unconditional on explicit close (no - /// per-surface persistence gate): a host session may exist from an earlier - /// launch regardless of the current toggle, and the kill invocation is a - /// silent no-op when nothing exists. - private func killZmxSessions(forSurfaceIDs surfaceIDs: [UUID], includeRemote: Bool = false) { - guard !surfaceIDs.isEmpty else { return } + /// `includeRemote` also tears down Supacode-owned host-side sessions of a + /// remote worktree; only explicit close paths set it, so a non-explicit end + /// (clean remote exit, deliberate host-side detach, or a reconnect abort) + /// spares the host session. Externally adopted sessions are skipped because + /// their lifecycle is managed by the caller. + private func killZmxSessions(forSessionIDs sessionIDs: [String], includeRemote: Bool = false) { + guard !sessionIDs.isEmpty else { return } let killLocal = zmxClient.isBundled() let host = includeRemote ? worktree.host : nil guard killLocal || host != nil else { return } - let sessionIDs = surfaceIDs.map(ZmxSessionID.make(surfaceID:)) let client = zmxClient analyticsClient.capture( "terminal_persistence_session_killed", @@ -2122,11 +2216,12 @@ final class WorktreeTerminalState { guard let tree = trees.removeValue(forKey: tabId) else { return } surfaceGenerationByTab.removeValue(forKey: tabId) let leafIDs = tree.leaves().map(\.id) + let sessionIDs = ownedZmxSessionIDs(forSurfaceIDs: leafIDs) for surface in tree.leaves() { surface.closeSurface() cleanupSurfaceState(for: surface.id) } - killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) + killZmxSessions(forSessionIDs: sessionIDs, includeRemote: true) focusedSurfaceIdByTab.removeValue(forKey: tabId) if lastTabProjections.removeValue(forKey: tabId) != nil { onTabRemoved?(tabId) @@ -2500,20 +2595,17 @@ final class WorktreeTerminalState { killZmxSession: Bool, includeRemoteSession: Bool = false ) { + let sessionIDs = killZmxSession ? ownedZmxSessionIDs(forSurfaceIDs: [view.id]) : [] guard let tabId = tabID(containing: view.id), let tree = trees[tabId] else { view.closeSurface() cleanupSurfaceState(for: view.id) - if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } + killZmxSessions(forSessionIDs: sessionIDs, includeRemote: includeRemoteSession) return } guard let node = tree.find(id: view.id) else { view.closeSurface() cleanupSurfaceState(for: view.id) - if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } + killZmxSessions(forSessionIDs: sessionIDs, includeRemote: includeRemoteSession) return } let nextSurface = @@ -2523,9 +2615,7 @@ final class WorktreeTerminalState { let newTree = tree.removing(node) view.closeSurface() cleanupSurfaceState(for: view.id) - if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } + killZmxSessions(forSessionIDs: sessionIDs, includeRemote: includeRemoteSession) if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) diff --git a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift index 1b780f5d0..a30a3bc59 100644 --- a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift @@ -54,15 +54,16 @@ struct TerminalsFeature { case .tabProjectionChanged(let worktreeID, let projection): let tabID = projection.tabID if state.terminalTabs[id: tabID] == nil { - // Drop stale projections arriving after the tab was removed in this - // worktree. Matching by (worktreeID, tabID) so a snapshot-restore - // under a different worktree wouldn't be shadowed; the per-worktree - // drain on teardown covers the same-worktree restore case. - guard - !state.recentlyRemovedTabIDs.contains(where: { - $0.worktreeID == worktreeID && $0.tabID == tabID - }) - else { return .none } + // Drop stale empty projections arriving after the tab was removed in + // this worktree, while still allowing an immediate same-ID re-add + // once it has a live surface. Today a non-empty projection after + // removal means the tree was synchronously rebuilt with that ID. + if let recentlyRemovedIndex = state.recentlyRemovedTabIDs.firstIndex(where: { + $0.worktreeID == worktreeID && $0.tabID == tabID + }) { + guard !projection.surfaceIDs.isEmpty else { return .none } + state.recentlyRemovedTabIDs.remove(at: recentlyRemovedIndex) + } state.terminalTabs.append( TerminalTabFeature.State(id: tabID, worktreeID: worktreeID) ) diff --git a/supacodeTests/AppFeatureCommandAckTests.swift b/supacodeTests/AppFeatureCommandAckTests.swift index db2d464e6..7ea5380ee 100644 --- a/supacodeTests/AppFeatureCommandAckTests.swift +++ b/supacodeTests/AppFeatureCommandAckTests.swift @@ -124,6 +124,65 @@ struct AppFeatureCommandAckTests { #expect(store.state.pendingCommandAcks[id: writeFD] != nil) } + // MARK: - tab adopt-zmx. + + @Test(.dependencies) func tabAdoptZmxSocketDeeplinkResolvesOnProjection() async { + let worktree = makeWorktree() + let tabID = UUID() + let store = makeStore(worktree: worktree, tabExists: false) + let (readFD, writeFD) = makePipe() + defer { close(readFD) } + + await store.send( + .deeplink( + .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "remote-session", title: "Remote", id: tabID)), + source: .socket, + responseFD: writeFD, + timeoutSeconds: 0 + ) + ) + #expect(store.state.pendingCommandAcks[id: writeFD] != nil) + + await store.send( + .terminalEvent( + .tabProjectionChanged( + worktreeID: worktree.id, + WorktreeTabProjection( + tabID: TerminalTabID(rawValue: tabID), + surfaceIDs: [tabID], + activeSurfaceID: tabID, + unseenNotificationCount: 0 + ) + ) + ) + ) + await store.finish() + + #expect(store.state.pendingCommandAcks.isEmpty) + #expect(readPipeJSON(readFD)?["ok"] as? Bool == true) + } + + @Test(.dependencies) func tabAdoptZmxExistingTabAcksImmediately() async { + let worktree = makeWorktree() + let tabID = UUID() + let store = makeStore(worktree: worktree, tabExists: true) + let (readFD, writeFD) = makePipe() + defer { close(readFD) } + + await store.send( + .deeplink( + .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "remote-session", title: nil, id: tabID)), + source: .socket, + responseFD: writeFD, + timeoutSeconds: 0 + ) + ) + await store.finish() + + #expect(store.state.pendingCommandAcks.isEmpty) + #expect(readPipeJSON(readFD)?["ok"] as? Bool == true) + } + // MARK: - worktree new. @Test(.dependencies) func worktreeNewAckBindsByPendingIDAndResolvesOnFirstTab() async { diff --git a/supacodeTests/DeeplinkClientTests.swift b/supacodeTests/DeeplinkClientTests.swift index ca84ec5f7..c6e747b2a 100644 --- a/supacodeTests/DeeplinkClientTests.swift +++ b/supacodeTests/DeeplinkClientTests.swift @@ -151,6 +151,39 @@ struct DeeplinkClientTests { #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .tabNew(input: "echo hello", id: nil))) } + @Test func worktreeTabAdoptZmx() { + let encoded = "%2Ftmp%2Frepo%2Fwt-1" + let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! + let url = URL( + string: + "supacode://worktree/\(encoded)/tab/adopt-zmx?session=remote-session.1&id=\(tabUUID.uuidString)&title=Remote" + )! + #expect( + parse(url) + == .worktree( + id: "/tmp/repo/wt-1", + action: .tabAdoptZmx(sessionID: "remote-session.1", title: "Remote", id: tabUUID)) + ) + + let spacedURL = URL( + string: "supacode://worktree/\(encoded)/tab/adopt-zmx?session=%20remote-session.1%20&id=\(tabUUID.uuidString)" + )! + #expect( + parse(spacedURL) + == .worktree( + id: "/tmp/repo/wt-1", + action: .tabAdoptZmx(sessionID: "remote-session.1", title: nil, id: tabUUID)) + ) + } + + @Test func worktreeTabAdoptZmxRequiresValidSessionAndID() { + let encoded = "%2Ftmp%2Frepo%2Fwt-1" + let validID = "550E8400-E29B-41D4-A716-446655440000" + #expect(parse(URL(string: "supacode://worktree/\(encoded)/tab/adopt-zmx?id=\(validID)")!) == nil) + #expect(parse(URL(string: "supacode://worktree/\(encoded)/tab/adopt-zmx?session=bad%2Fname&id=\(validID)")!) == nil) + #expect(parse(URL(string: "supacode://worktree/\(encoded)/tab/adopt-zmx?session=remote-session&id=nope")!) == nil) + } + @Test func worktreeTabDestroy() { let encoded = "%2Ftmp%2Frepo%2Fwt-1" let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! diff --git a/supacodeTests/TerminalsFeatureTests.swift b/supacodeTests/TerminalsFeatureTests.swift index 08905009f..57dc3d9f2 100644 --- a/supacodeTests/TerminalsFeatureTests.swift +++ b/supacodeTests/TerminalsFeatureTests.swift @@ -77,6 +77,40 @@ struct TerminalsFeatureTests { #expect(store.state.terminalTabs.isEmpty) } + @Test func tabProjectionWithSurfacesAfterRemoveReinsertsSameIDTab() async { + let tabID = TerminalTabID(rawValue: UUID()) + let surface = UUID() + var initial = TerminalsFeature.State() + initial.terminalTabs.append( + TerminalTabFeature.State(id: tabID, worktreeID: "/tmp/repo") + ) + let store = TestStore(initialState: initial) { TerminalsFeature() } + store.exhaustivity = .off + + await store.send(.tabRemoved(worktreeID: "/tmp/repo", tabID: tabID)) { + $0.terminalTabs.remove(id: tabID) + $0.recentlyRemovedTabIDs = [ + TerminalsFeature.RecentlyRemovedTab(worktreeID: "/tmp/repo", tabID: tabID) + ] + } + + await store.send( + .tabProjectionChanged( + worktreeID: "/tmp/repo", + projection: WorktreeTabProjection( + tabID: tabID, + surfaceIDs: [surface], + activeSurfaceID: surface, + unseenNotificationCount: 0 + ) + ) + ) { + $0.recentlyRemovedTabIDs = [] + $0.terminalTabs.append(TerminalTabFeature.State(id: tabID, worktreeID: "/tmp/repo")) + } + await store.receive(\.terminalTabs) + } + @Test func recentlyRemovedTabIDsAreBoundedByLimit() async { let initial = TerminalsFeature.State() let store = TestStore(initialState: initial) { TerminalsFeature() } diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index d09de6afc..ab9b2139f 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -910,6 +910,132 @@ struct WorktreeTerminalManagerTests { #expect(remoteKills.contains(.init(authority: "devbox", sessionID: sessionID))) } + @Test func adoptZmxSessionReplacesSameIDRemotePersistenceTab() async { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + let requestedID = UUID() + let terminalTabID = TerminalTabID(rawValue: requestedID) + guard let staleTabID = state.createTab(tabID: requestedID), + let staleSurfaceID = state.surfaceIDs(inTab: staleTabID).first + else { + Issue.record("Expected stale tab and surface") + return + } + + let adopted = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) + + #expect(adopted == terminalTabID) + #expect(state.tabManager.tabs.map(\.id) == [terminalTabID]) + #expect(state.tabManager.tabs.first?.displayTitle == "Agent") + #expect(state.surfaceIDs(inTab: terminalTabID) == [requestedID]) + let staleSessionID = session(for: staleSurfaceID) + await probe.waitForRemoteKill { $0.contains(.init(authority: "devbox", sessionID: staleSessionID)) } + + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) + + let remoteKills = await probe.remoteKilledSessions() + #expect(remoteKills.filter { $0 == .init(authority: "devbox", sessionID: staleSessionID) }.count == 1) + } + + @Test func reAdoptingSameZmxSessionDoesNotStealFocus() { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + guard let primaryTabID = state.createTab(focusing: true) else { + Issue.record("Expected primary tab") + return + } + let requestedID = UUID() + let terminalTabID = TerminalTabID(rawValue: requestedID) + + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) + state.tabManager.selectTab(primaryTabID) + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) + + #expect(state.tabManager.selectedTabId == primaryTabID) + #expect(state.tabManager.tabs.map(\.id) == [primaryTabID, terminalTabID]) + } + + @Test func closeAdoptedZmxTabDoesNotKillExternalSession() async { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + let requestedID = UUID() + let terminalTabID = TerminalTabID(rawValue: requestedID) + + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) + state.closeTab(terminalTabID) + + let killed = await probe.killedSessions() + let remoteKills = await probe.remoteKilledSessions() + #expect(killed.isEmpty) + #expect(remoteKills.isEmpty) + } + + @Test func pruneSkipsAdoptedExternalZmxSessions() async { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: UUID()) + manager.prune(keeping: []) + + let killed = await probe.killedSessions() + let remoteKills = await probe.remoteKilledSessions() + #expect(killed.isEmpty) + #expect(remoteKills.isEmpty) + } + + @Test func explicitSurfaceCloseOfAdoptedZmxSessionDoesNotKillExternalSession() async { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + let requestedID = UUID() + let terminalTabID = TerminalTabID(rawValue: requestedID) + guard state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) != nil, + let surface = state.splitTree(for: terminalTabID).root?.leftmostLeaf() + else { + Issue.record("Expected adopted tab and surface") + return + } + + #expect(state.closeSurface(id: surface.id)) + surface.bridge.closeSurface(processAlive: false) + + let killed = await probe.killedSessions() + let remoteKills = await probe.remoteKilledSessions() + #expect(killed.isEmpty) + #expect(remoteKills.isEmpty) + } + + @Test func adoptedZmxSurfaceExitDoesNotKillExternalSession() async { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + let requestedID = UUID() + let terminalTabID = TerminalTabID(rawValue: requestedID) + guard state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: requestedID) != nil, + let surface = state.splitTree(for: terminalTabID).root?.leftmostLeaf() + else { + Issue.record("Expected adopted tab and surface") + return + } + + surface.bridge.closeSurface(processAlive: false) + + let killed = await probe.killedSessions() + let remoteKills = await probe.remoteKilledSessions() + #expect(killed.isEmpty) + #expect(remoteKills.isEmpty) + } + @Test func unexpectedRemoteSurfaceExitSparesHostSession() async { // A non-explicit close (clean remote exit or a deliberate host-side // detach) must not tear down the host session. @@ -2610,6 +2736,26 @@ struct WorktreeTerminalManagerTests { #expect(snapshot.tabs.first?.title != "Archive Script") } + @Test func captureLayoutSnapshotExcludesAdoptedExternalZmxTabs() { + let probe = ZmxTestProbe(listing: []) + let worktree = makeRemoteWorktree() + let manager = makeZmxBackedManager(probe: probe, worktree: worktree) + let state = manager.state(for: worktree) + guard let regularTabID = state.createTab() else { + Issue.record("Expected regular tab") + return + } + + _ = state.adoptZmxSession(sessionID: "external-session-1", title: "Agent", tabID: UUID()) + + guard let snapshot = state.captureLayoutSnapshot() else { + Issue.record("Expected non-nil snapshot") + return + } + #expect(snapshot.tabs.map(\.id) == [regularTabID.rawValue]) + #expect(snapshot.selectedTabIndex == 0) + } + @Test func captureLayoutSnapshotExcludesCompletedBlockingScriptTabs() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() diff --git a/supacodeTests/ZmxClientTests.swift b/supacodeTests/ZmxClientTests.swift index 29515c7de..63a65923a 100644 --- a/supacodeTests/ZmxClientTests.swift +++ b/supacodeTests/ZmxClientTests.swift @@ -1,4 +1,5 @@ import Foundation +import SupacodeSettingsShared import Testing @testable import supacode @@ -32,6 +33,24 @@ struct ZmxSessionIDTests { } } +@MainActor +struct ZmxExternalSessionNameTests { + @Test func normalizedTrimsSupportedNames() { + #expect( + ZmxExternalSessionName.normalized(" remote session@1:main_ok ") + == "remote session@1:main_ok" + ) + } + + @Test func normalizedRejectsEmptyAndUnsupportedNames() { + #expect(ZmxExternalSessionName.normalized(" \n ") == nil) + #expect(ZmxExternalSessionName.normalized("remote/session") == nil) + #expect(ZmxExternalSessionName.normalized(".") == nil) + #expect(ZmxExternalSessionName.normalized("..") == nil) + #expect(ZmxExternalSessionName.normalized("remote\u{7f}session") == nil) + } +} + @MainActor struct ZmxAttachTests { @Test func buildCommandWithoutUserCommandUsesAttachOnly() { @@ -98,6 +117,35 @@ struct ZmxAttachTests { #expect(argv[1] == "attach") #expect(argv[2] == "supa-1") } + + @Test func buildExternalAttachCommandAttachesExistingSession() { + let cmd = ZmxAttach.buildExternalAttachCommand( + executablePath: "/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx", + sessionID: "remote session@1:main_ok" + ) + #expect( + cmd + == "if '/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx' list --short " + + "| grep -Fx -- 'remote session@1:main_ok' >/dev/null; " + + "then exec '/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx' " + + "attach 'remote session@1:main_ok'; " + + "else printf 'zmx session not found: %s\\n' 'remote session@1:main_ok'; exit 1; fi" + ) + } + + @Test func buildExternalAttachCommandRejectsUnsupportedSessionName() { + #expect(ZmxAttach.buildExternalAttachCommand(executablePath: "/zmx", sessionID: "remote/session") == nil) + } + + @Test func buildRemoteExternalAttachCommandRequiresExistingSession() { + let cmd = ZmxAttach.buildRemoteExternalAttachCommand( + host: RemoteHost(alias: "remote-build"), sessionID: "external-session-1") + #expect(cmd?.contains("zmx list --short") == true) + #expect(cmd?.contains("grep -Fx") == true) + #expect(cmd?.contains("zmx attach") == true) + #expect(cmd?.contains("external-session-1") == true) + #expect(cmd?.contains("zmx session not found") == true) + } } @MainActor