From 25b4abee57d8ba1515e49d17d873f117086644a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 8 Jul 2026 09:48:30 +0800 Subject: [PATCH 1/5] Add CLI command to adopt zmx sessions --- supacode-cli/Commands/TabCommand.swift | 86 +++++++++++++++++++ supacode-cli/Helpers/DeeplinkURLBuilder.swift | 11 +++ .../Clients/Deeplink/DeeplinkClient.swift | 15 ++++ .../Clients/Terminal/TerminalClient.swift | 1 + supacode/Clients/Zmx/ZmxClient.swift | 25 ++++++ supacode/Domain/Deeplink.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 25 +++++- .../WorktreeTerminalManager.swift | 31 ++++++- .../Terminal/Models/TerminalTabManager.swift | 4 +- .../Models/WorktreeTerminalState.swift | 58 +++++++++++++ supacodeTests/AppFeatureCommandAckTests.swift | 59 +++++++++++++ supacodeTests/DeeplinkClientTests.swift | 23 +++++ supacodeTests/ZmxClientTests.swift | 33 +++++++ 13 files changed, 367 insertions(+), 5 deletions(-) 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..ad1155136 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 sessionID = queryItems.first(where: { $0.name == "session" })?.value, + ZmxExternalSessionName.normalized(sessionID) != nil + 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..ac0a65795 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,17 @@ nonisolated enum ZmxAttach { [executablePath, "attach", sessionID] } + static func buildExternalAttachCommand(executablePath: String, sessionID: String) -> String? { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil } + return "\(shellQuote(executablePath)) attach \(shellQuote(sessionID))" + } + + static func buildRemoteExternalAttachCommand(host: RemoteHost, sessionID: String) -> String? { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil } + let remote = posixShellWrapped("exec zmx attach \(shellQuote(sessionID))") + return SSHCommand.commandLine(host: host, remoteCommand: remote) + } + /// 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..0191e9438 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 ZmxExternalSessionName.normalized(sessionID) != nil 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..4ee8aff72 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) 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..d54d45f6f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -286,6 +286,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 +343,49 @@ 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 let title { tabManager.setCustomTitle(terminalTabID, title: title) } + if focusing { selectTab(terminalTabID) } + return 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, + ) + ) + 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 { @@ -506,6 +556,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 diff --git a/supacodeTests/AppFeatureCommandAckTests.swift b/supacodeTests/AppFeatureCommandAckTests.swift index db2d464e6..97a643b2c 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: "devbox-tiktok", title: "TikTok", 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: "devbox-tiktok", 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..1be89f445 100644 --- a/supacodeTests/DeeplinkClientTests.swift +++ b/supacodeTests/DeeplinkClientTests.swift @@ -151,6 +151,29 @@ 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=devbox-tiktok.1&id=\(tabUUID.uuidString)&title=TikTok" + )! + #expect( + parse(url) + == .worktree( + id: "/tmp/repo/wt-1", + action: .tabAdoptZmx(sessionID: "devbox-tiktok.1", title: "TikTok", 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=devbox&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/ZmxClientTests.swift b/supacodeTests/ZmxClientTests.swift index 29515c7de..7e82277d3 100644 --- a/supacodeTests/ZmxClientTests.swift +++ b/supacodeTests/ZmxClientTests.swift @@ -32,6 +32,24 @@ struct ZmxSessionIDTests { } } +@MainActor +struct ZmxExternalSessionNameTests { + @Test func normalizedTrimsSupportedNames() { + #expect( + ZmxExternalSessionName.normalized(" devbox tiktok@1:main_ok ") + == "devbox tiktok@1:main_ok" + ) + } + + @Test func normalizedRejectsEmptyAndUnsupportedNames() { + #expect(ZmxExternalSessionName.normalized(" \n ") == nil) + #expect(ZmxExternalSessionName.normalized("devbox/tiktok") == nil) + #expect(ZmxExternalSessionName.normalized(".") == nil) + #expect(ZmxExternalSessionName.normalized("..") == nil) + #expect(ZmxExternalSessionName.normalized("devbox\u{7f}tiktok") == nil) + } +} + @MainActor struct ZmxAttachTests { @Test func buildCommandWithoutUserCommandUsesAttachOnly() { @@ -98,6 +116,21 @@ 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: "devbox tiktok@1:main_ok" + ) + #expect( + cmd + == "'/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx' attach 'devbox tiktok@1:main_ok'" + ) + } + + @Test func buildExternalAttachCommandRejectsUnsupportedSessionName() { + #expect(ZmxAttach.buildExternalAttachCommand(executablePath: "/zmx", sessionID: "devbox/tiktok") == nil) + } } @MainActor From 87b33343f877986311781afe9f62348a4067f1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 8 Jul 2026 09:48:30 +0800 Subject: [PATCH 2/5] Fix existing zmx session adoption --- .../Clients/Deeplink/DeeplinkClient.swift | 4 +- supacode/Clients/Zmx/ZmxClient.swift | 11 +++++- .../Features/App/Reducer/AppFeature.swift | 2 +- .../Models/WorktreeTerminalState.swift | 37 +++++++++++++++---- .../Terminal/Reducer/TerminalsFeature.swift | 18 ++++----- supacodeTests/AppFeatureCommandAckTests.swift | 4 +- supacodeTests/DeeplinkClientTests.swift | 16 ++++++-- supacodeTests/TerminalsFeatureTests.swift | 34 +++++++++++++++++ .../WorktreeTerminalManagerTests.swift | 29 +++++++++++++++ supacodeTests/ZmxClientTests.swift | 29 +++++++++++---- 10 files changed, 151 insertions(+), 33 deletions(-) diff --git a/supacode/Clients/Deeplink/DeeplinkClient.swift b/supacode/Clients/Deeplink/DeeplinkClient.swift index ad1155136..4c3b35986 100644 --- a/supacode/Clients/Deeplink/DeeplinkClient.swift +++ b/supacode/Clients/Deeplink/DeeplinkClient.swift @@ -204,8 +204,8 @@ private nonisolated enum DeeplinkParser { return .worktree(id: worktreeID, action: .tabNew(input: input, id: id)) } if thirdSegment == "adopt-zmx" { - guard let sessionID = queryItems.first(where: { $0.name == "session" })?.value, - ZmxExternalSessionName.normalized(sessionID) != nil + 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 diff --git a/supacode/Clients/Zmx/ZmxClient.swift b/supacode/Clients/Zmx/ZmxClient.swift index ac0a65795..38775644b 100644 --- a/supacode/Clients/Zmx/ZmxClient.swift +++ b/supacode/Clients/Zmx/ZmxClient.swift @@ -404,15 +404,22 @@ nonisolated enum ZmxAttach { static func buildExternalAttachCommand(executablePath: String, sessionID: String) -> String? { guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { return nil } - return "\(shellQuote(executablePath)) attach \(shellQuote(sessionID))" + 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("exec zmx attach \(shellQuote(sessionID))") + 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/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 0191e9438..ef4014508 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -2009,7 +2009,7 @@ struct AppFeature { effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, responseFD: responseFD, timeoutSeconds: timeoutSeconds, state: &state) case .tabAdoptZmx(let sessionID, let title, let id): - guard ZmxExternalSessionName.normalized(sessionID) != nil else { + guard let sessionID = ZmxExternalSessionName.normalized(sessionID) else { state.alert = AlertState { TextState("Invalid zmx session") } actions: { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index d54d45f6f..0b64f346a 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 @@ -356,9 +357,12 @@ final class WorktreeTerminalState { restorePendingLayoutIfNeeded(focusing: false) let terminalTabID = TerminalTabID(rawValue: requestedTabID) if hasTab(terminalTabID) { - if let title { tabManager.setCustomTitle(terminalTabID, title: title) } - if focusing { selectTab(terminalTabID) } - return terminalTabID + if externalZmxSessionID(in: terminalTabID) == sessionID { + if let title { tabManager.setCustomTitle(terminalTabID, title: title) } + if focusing { selectTab(terminalTabID) } + return terminalTabID + } + closeTab(terminalTabID) } let resolvedTitle: String if let trimmedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedTitle.isEmpty { @@ -378,6 +382,7 @@ final class WorktreeTerminalState { context: tabManager.tabs.isEmpty ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB, tabID: requestedTabID, bypassZmx: true, + externalZmxSessionID: sessionID, ) ) if let tabId, let title { @@ -521,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? { @@ -546,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() { @@ -574,6 +581,15 @@ 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 + } + func hasTab(_ tabId: TerminalTabID) -> Bool { tabManager.tabs.contains(where: { $0.id == tabId }) } @@ -854,7 +870,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 @@ -866,7 +883,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) @@ -1539,6 +1557,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 @@ -1584,7 +1603,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 } diff --git a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift index 1b780f5d0..4285b78c3 100644 --- a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift @@ -54,15 +54,15 @@ 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. + 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 97a643b2c..7ea5380ee 100644 --- a/supacodeTests/AppFeatureCommandAckTests.swift +++ b/supacodeTests/AppFeatureCommandAckTests.swift @@ -135,7 +135,7 @@ struct AppFeatureCommandAckTests { await store.send( .deeplink( - .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "devbox-tiktok", title: "TikTok", id: tabID)), + .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "remote-session", title: "Remote", id: tabID)), source: .socket, responseFD: writeFD, timeoutSeconds: 0 @@ -171,7 +171,7 @@ struct AppFeatureCommandAckTests { await store.send( .deeplink( - .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "devbox-tiktok", title: nil, id: tabID)), + .worktree(id: worktree.id, action: .tabAdoptZmx(sessionID: "remote-session", title: nil, id: tabID)), source: .socket, responseFD: writeFD, timeoutSeconds: 0 diff --git a/supacodeTests/DeeplinkClientTests.swift b/supacodeTests/DeeplinkClientTests.swift index 1be89f445..c6e747b2a 100644 --- a/supacodeTests/DeeplinkClientTests.swift +++ b/supacodeTests/DeeplinkClientTests.swift @@ -156,13 +156,23 @@ struct DeeplinkClientTests { let tabUUID = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")! let url = URL( string: - "supacode://worktree/\(encoded)/tab/adopt-zmx?session=devbox-tiktok.1&id=\(tabUUID.uuidString)&title=TikTok" + "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: "devbox-tiktok.1", title: "TikTok", id: tabUUID)) + 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)) ) } @@ -171,7 +181,7 @@ struct DeeplinkClientTests { 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=devbox&id=nope")!) == nil) + #expect(parse(URL(string: "supacode://worktree/\(encoded)/tab/adopt-zmx?session=remote-session&id=nope")!) == nil) } @Test func worktreeTabDestroy() { 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..f455ced73 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -910,6 +910,35 @@ 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 unexpectedRemoteSurfaceExitSparesHostSession() async { // A non-explicit close (clean remote exit or a deliberate host-side // detach) must not tear down the host session. diff --git a/supacodeTests/ZmxClientTests.swift b/supacodeTests/ZmxClientTests.swift index 7e82277d3..63a65923a 100644 --- a/supacodeTests/ZmxClientTests.swift +++ b/supacodeTests/ZmxClientTests.swift @@ -1,4 +1,5 @@ import Foundation +import SupacodeSettingsShared import Testing @testable import supacode @@ -36,17 +37,17 @@ struct ZmxSessionIDTests { struct ZmxExternalSessionNameTests { @Test func normalizedTrimsSupportedNames() { #expect( - ZmxExternalSessionName.normalized(" devbox tiktok@1:main_ok ") - == "devbox tiktok@1:main_ok" + ZmxExternalSessionName.normalized(" remote session@1:main_ok ") + == "remote session@1:main_ok" ) } @Test func normalizedRejectsEmptyAndUnsupportedNames() { #expect(ZmxExternalSessionName.normalized(" \n ") == nil) - #expect(ZmxExternalSessionName.normalized("devbox/tiktok") == nil) + #expect(ZmxExternalSessionName.normalized("remote/session") == nil) #expect(ZmxExternalSessionName.normalized(".") == nil) #expect(ZmxExternalSessionName.normalized("..") == nil) - #expect(ZmxExternalSessionName.normalized("devbox\u{7f}tiktok") == nil) + #expect(ZmxExternalSessionName.normalized("remote\u{7f}session") == nil) } } @@ -120,16 +121,30 @@ struct ZmxAttachTests { @Test func buildExternalAttachCommandAttachesExistingSession() { let cmd = ZmxAttach.buildExternalAttachCommand( executablePath: "/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx", - sessionID: "devbox tiktok@1:main_ok" + sessionID: "remote session@1:main_ok" ) #expect( cmd - == "'/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx' attach 'devbox tiktok@1:main_ok'" + == "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: "devbox/tiktok") == nil) + #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) } } From 9af6f1ad3e70b5e28d3f2bb34065e582b02d0a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 8 Jul 2026 09:48:30 +0800 Subject: [PATCH 3/5] Harden external zmx session adoption --- .../WorktreeTerminalManager.swift | 12 ++-- .../Models/WorktreeTerminalState.swift | 42 ++++++++--- .../Terminal/Reducer/TerminalsFeature.swift | 3 +- .../WorktreeTerminalManagerTests.swift | 72 +++++++++++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4ee8aff72..a23e28e80 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -657,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 { @@ -684,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/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 0b64f346a..8fff123f3 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -359,7 +359,7 @@ final class WorktreeTerminalState { if hasTab(terminalTabID) { if externalZmxSessionID(in: terminalTabID) == sessionID { if let title { tabManager.setCustomTitle(terminalTabID, title: title) } - if focusing { selectTab(terminalTabID) } + // Re-applying the same external session is a sync no-op; do not steal focus. return terminalTabID } closeTab(terminalTabID) @@ -590,6 +590,12 @@ final class WorktreeTerminalState { 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 }) } @@ -605,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? { @@ -1157,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 @@ -2164,19 +2181,21 @@ final class WorktreeTerminalState { /// `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. + /// `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(forSurfaceIDs surfaceIDs: [UUID], includeRemote: Bool = false) { - guard !surfaceIDs.isEmpty else { return } + let sessionIDs = ownedZmxSessionIDs(forSurfaceIDs: surfaceIDs) + killZmxSessions(forSessionIDs: sessionIDs, includeRemote: includeRemote) + } + + 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", @@ -2203,11 +2222,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) diff --git a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift index 4285b78c3..a30a3bc59 100644 --- a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift @@ -56,7 +56,8 @@ struct TerminalsFeature { if state.terminalTabs[id: tabID] == nil { // 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. + // 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 }) { diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index f455ced73..4cfc7f518 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -939,6 +939,58 @@ struct WorktreeTerminalManagerTests { #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 unexpectedRemoteSurfaceExitSparesHostSession() async { // A non-explicit close (clean remote exit or a deliberate host-side // detach) must not tear down the host session. @@ -2639,6 +2691,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() From b5367c09853ff8634ec3925bd6244ae74fc98108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 8 Jul 2026 09:57:24 +0800 Subject: [PATCH 4/5] Skip adopted zmx sessions on surface close --- .../Models/WorktreeTerminalState.swift | 13 ++---- .../WorktreeTerminalManagerTests.swift | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8fff123f3..fa9e4d7f5 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -2601,20 +2601,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 = @@ -2624,9 +2621,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/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 4cfc7f518..ab9b2139f 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -991,6 +991,51 @@ struct WorktreeTerminalManagerTests { #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. From 583857a63af7b500347a7bd894b3babee9213958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A1=E8=BE=9E?= Date: Wed, 8 Jul 2026 10:03:04 +0800 Subject: [PATCH 5/5] Remove unused zmx kill wrapper --- .../Terminal/Models/WorktreeTerminalState.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index fa9e4d7f5..3a26209e3 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -2158,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) { @@ -2177,7 +2176,7 @@ 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. @@ -2186,11 +2185,6 @@ final class WorktreeTerminalState { /// (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(forSurfaceIDs surfaceIDs: [UUID], includeRemote: Bool = false) { - let sessionIDs = ownedZmxSessionIDs(forSurfaceIDs: surfaceIDs) - killZmxSessions(forSessionIDs: sessionIDs, includeRemote: includeRemote) - } - private func killZmxSessions(forSessionIDs sessionIDs: [String], includeRemote: Bool = false) { guard !sessionIDs.isEmpty else { return } let killLocal = zmxClient.isBundled()