From d38950fbc96cada38dc40267a57072f5cdf70f65 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 25 May 2026 23:18:11 -0700 Subject: [PATCH 01/10] Test agent restore startup command launch --- .../AgentSessionAutoResumeSettingsTests.swift | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 1a7cbd03cc01..6f5f5f8d9967 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -81,8 +81,12 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let autoResumePanelId = try XCTUnwrap(restoredWithAutoResume.focusedPanelId) let autoResumePanel = try XCTUnwrap(restoredWithAutoResume.terminalPanel(for: autoResumePanelId)) let autoResumeInput = autoResumePanel.surface.debugInitialInputMetadata() - XCTAssertTrue(autoResumeInput.hasInitialInput) - XCTAssertGreaterThan(autoResumeInput.byteCount, 0) + XCTAssertFalse(autoResumeInput.hasInitialInput) + XCTAssertEqual(autoResumeInput.byteCount, 0) + try assertAgentAutoResumeUsesStartupCommand( + autoResumePanel, + scriptContains: ["'resume'", "codex-auto-resume-disabled-session"] + ) defaults.set(false, forKey: key) let restoredWithoutAutoResume = Workspace() @@ -92,6 +96,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let disabledInput = disabledPanel.surface.debugInitialInputMetadata() XCTAssertFalse(disabledInput.hasInitialInput) XCTAssertEqual(disabledInput.byteCount, 0) + XCTAssertNil(disabledPanel.surface.debugInitialCommand()) XCTAssertEqual( restoredWithoutAutoResume.sessionSnapshot(includeScrollback: false) .panels.first?.terminal?.agent?.sessionId, @@ -142,6 +147,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertFalse(restoredInput.hasInitialInput, "must not auto-resume when agent was already exited at snapshot time") + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, "codex-exited-before-snapshot-session", @@ -178,9 +184,12 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let restoredInput = restoredPanel.surface.debugInitialInputMetadata() - XCTAssertTrue(restoredInput.hasInitialInput, - "must auto-resume when agent was running at snapshot time and setting is enabled") - XCTAssertGreaterThan(restoredInput.byteCount, 0) + XCTAssertFalse(restoredInput.hasInitialInput) + XCTAssertEqual(restoredInput.byteCount, 0) + try assertAgentAutoResumeUsesStartupCommand( + restoredPanel, + scriptContains: ["'resume'", "codex-running-at-snapshot-session"] + ) } } @@ -209,9 +218,12 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let restoredInput = restoredPanel.surface.debugInitialInputMetadata() - XCTAssertTrue(restoredInput.hasInitialInput, - "unknown shell state should still auto-resume through the nil back-compat path") - XCTAssertGreaterThan(restoredInput.byteCount, 0) + XCTAssertFalse(restoredInput.hasInitialInput) + XCTAssertEqual(restoredInput.byteCount, 0) + try assertAgentAutoResumeUsesStartupCommand( + restoredPanel, + scriptContains: ["'resume'", "codex-unknown-shell-state-session"] + ) } } @@ -257,6 +269,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertFalse(input.hasInitialInput) XCTAssertEqual(input.byteCount, 0) + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, "codex-exited-binding-session" @@ -314,6 +327,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertFalse(input.hasInitialInput) XCTAssertEqual(input.byteCount, 0) + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.resumeBinding?.source, "agent-hook" @@ -372,6 +386,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertFalse(input.hasInitialInput) XCTAssertEqual(input.byteCount, 0) + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) XCTAssertEqual( restored.sessionSnapshot(includeScrollback: true).panels.first?.terminal?.scrollback, savedScrollback @@ -421,7 +436,13 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { restored.restoreSessionSnapshot(snapshot) let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) - XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + let input = restoredPanel.surface.debugInitialInputMetadata() + XCTAssertFalse(input.hasInitialInput) + XCTAssertEqual(input.byteCount, 0) + try assertAgentAutoResumeUsesStartupCommand( + restoredPanel, + scriptContains: ["codex resume codex-binding-auto-resume-session"] + ) restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) XCTAssertEqual( @@ -479,6 +500,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) XCTAssertTrue(restoredPanel.surface.debugInitialInputMetadata().hasInitialInput) + XCTAssertNil(restoredPanel.surface.debugInitialCommand()) restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) let runningSnapshot = restored.sessionSnapshot(includeScrollback: false) @@ -502,6 +524,24 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { return try body() } + @MainActor + private func assertAgentAutoResumeUsesStartupCommand( + _ panel: TerminalPanel, + scriptContains needles: [String], + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let command = try XCTUnwrap(panel.surface.debugInitialCommand(), file: file, line: line) + XCTAssertTrue(command.hasPrefix("/bin/zsh '"), command, file: file, line: line) + let scriptPath = String(command.dropFirst("/bin/zsh '".count).dropLast()) + defer { try? FileManager.default.removeItem(atPath: scriptPath) } + let script = try String(contentsOfFile: scriptPath, encoding: .utf8) + for needle in needles { + XCTAssertTrue(script.contains(needle), script, file: file, line: line) + } + XCTAssertTrue(script.contains("exec \"${SHELL:-/bin/zsh}\" -l"), script, file: file, line: line) + } + private func makeRestorableAgentIndex( workspaceId: UUID, panelId: UUID, From a97ca84c0d924ecb39aa4b9c7988ac7b4e20e77b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 25 May 2026 23:18:20 -0700 Subject: [PATCH 02/10] Launch restored agents with startup commands --- Sources/RestorableAgentSession.swift | 35 +++++++-- Sources/SessionPersistence.swift | 28 ++++++- Sources/Workspace.swift | 106 ++++++++++++++++++++++----- 3 files changed, 143 insertions(+), 26 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index c054a0f2dd10..453b31d3dd58 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -588,6 +588,24 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { ) } + func resumeStartupCommand( + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> String? { + guard let command = resumeCommand, + let scriptURL = AgentResumeScriptStore.writeLauncherScript( + command: command, + kind: kind, + sessionId: sessionId, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory, + returnToLoginShell: true + ) else { + return nil + } + return "/bin/zsh \(shellSingleQuoted(scriptURL.path))" + } + func forkStartupInput( fileManager: FileManager = .default, temporaryDirectory: URL = FileManager.default.temporaryDirectory, @@ -647,7 +665,8 @@ private enum AgentResumeScriptStore { kind: RestorableAgentKind, sessionId: String, fileManager: FileManager, - temporaryDirectory: URL + temporaryDirectory: URL, + returnToLoginShell: Bool = false ) -> URL? { let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) do { @@ -664,11 +683,15 @@ private enum AgentResumeScriptStore { "\(kind.rawValue)-\(String(safeSessionPrefix))-\(UUID().uuidString).zsh", isDirectory: false ) - let contents = """ - #!/bin/zsh - rm -f -- "$0" 2>/dev/null || true - \(command) - """ + var lines = [ + "#!/bin/zsh", + "rm -f -- \"$0\" 2>/dev/null || true", + command + ] + if returnToLoginShell { + lines.append("exec \"${SHELL:-/bin/zsh}\" -l") + } + let contents = lines.joined(separator: "\n") + "\n" try contents.write(to: scriptURL, atomically: true, encoding: .utf8) try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: scriptURL.path) return scriptURL diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4878dbeace03..d4c987e2ae93 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -361,6 +361,23 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil } + func startupCommandWithLauncherScript( + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> String? { + guard let inlineInput = inlineStartupInput, + let scriptURL = SurfaceResumeBindingScriptStore.writeLauncherScript( + inlineInput: inlineInput, + binding: self, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory, + returnToLoginShell: true + ) else { + return nil + } + return "/bin/zsh \(Self.shellSingleQuoted(scriptURL.path))" + } + private static func normalized(_ rawValue: String?) -> String? { guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { @@ -1203,7 +1220,8 @@ private enum SurfaceResumeBindingScriptStore { inlineInput: String, binding: SurfaceResumeBindingSnapshot, fileManager: FileManager, - temporaryDirectory: URL + temporaryDirectory: URL, + returnToLoginShell: Bool = false ) -> URL? { let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) do { @@ -1216,7 +1234,13 @@ private enum SurfaceResumeBindingScriptStore { "\(prefix)-\(UUID().uuidString).zsh", isDirectory: false ) - let contents = "#!/bin/zsh\nrm -f -- \"$0\" 2>/dev/null || true\n\(inlineInput)" + var contents = "#!/bin/zsh\nrm -f -- \"$0\" 2>/dev/null || true\n\(inlineInput)" + if returnToLoginShell { + if !contents.hasSuffix("\n") { + contents.append("\n") + } + contents.append("exec \"${SHELL:-/bin/zsh}\" -l\n") + } try contents.write(to: scriptURL, atomically: true, encoding: .utf8) try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) return scriptURL diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3748412d1a9f..f0b46aad86bf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -805,6 +805,15 @@ extension Workspace { return !snapshot.panels.contains { $0.terminal != nil } } + nonisolated struct SurfaceResumeStartupLaunch { + var initialCommand: String? + var initialInput: String? + + var hasStartupWork: Bool { + initialCommand != nil || initialInput != nil + } + } + nonisolated static func surfaceResumeStartupInput( _ resumeBinding: SurfaceResumeBindingSnapshot?, autoResumeAgentSessions: Bool, @@ -813,6 +822,60 @@ extension Workspace { approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(), approvalSigningSecret: Data? = nil ) -> String? { + guard let effectiveBinding = approvedSurfaceResumeBinding( + resumeBinding, + autoResumeAgentSessions: autoResumeAgentSessions, + promptForApproval: promptForApproval, + approvalStoreURL: approvalStoreURL, + approvalSigningSecret: approvalSigningSecret + ) else { + return nil + } + return effectiveBinding.startupInputWithLauncherScript(allowLauncherScript: allowLauncherScript) + } + + nonisolated static func surfaceResumeStartupLaunch( + _ resumeBinding: SurfaceResumeBindingSnapshot?, + autoResumeAgentSessions: Bool, + allowLauncherScript: Bool = true, + promptForApproval: Bool = true, + approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(), + approvalSigningSecret: Data? = nil, + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> SurfaceResumeStartupLaunch? { + guard let effectiveBinding = approvedSurfaceResumeBinding( + resumeBinding, + autoResumeAgentSessions: autoResumeAgentSessions, + promptForApproval: promptForApproval, + approvalStoreURL: approvalStoreURL, + approvalSigningSecret: approvalSigningSecret + ) else { + return nil + } + if effectiveBinding.isAgentHookBinding, + allowLauncherScript, + let command = effectiveBinding.startupCommandWithLauncherScript( + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) { + return SurfaceResumeStartupLaunch(initialCommand: command, initialInput: nil) + } + guard let input = effectiveBinding.startupInputWithLauncherScript( + allowLauncherScript: allowLauncherScript + ) else { + return nil + } + return SurfaceResumeStartupLaunch(initialCommand: nil, initialInput: input) + } + + nonisolated private static func approvedSurfaceResumeBinding( + _ resumeBinding: SurfaceResumeBindingSnapshot?, + autoResumeAgentSessions: Bool, + promptForApproval: Bool, + approvalStoreURL: URL, + approvalSigningSecret: Data? + ) -> SurfaceResumeBindingSnapshot? { guard let resumeBinding else { return nil } let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval( to: resumeBinding, @@ -825,10 +888,10 @@ extension Workspace { if effectiveBinding.approvalPolicy == .prompt { guard promptForApproval else { return nil } guard shouldRunPromptedSurfaceResume(effectiveBinding) else { return nil } - return effectiveBinding.startupInputWithLauncherScript(allowLauncherScript: allowLauncherScript) + return effectiveBinding } guard effectiveBinding.allowsAutomaticResume else { return nil } - return effectiveBinding.startupInputWithLauncherScript(allowLauncherScript: allowLauncherScript) + return effectiveBinding } nonisolated private static func shouldRunPromptedSurfaceResume(_ binding: SurfaceResumeBindingSnapshot) -> Bool { @@ -1112,12 +1175,12 @@ extension Workspace { (resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true) ? nil : resumeBinding - let restoredBindingInput = Self.surfaceResumeStartupInput( + let restoredBindingLaunch = Self.surfaceResumeStartupLaunch( resumeBindingForStartup, autoResumeAgentSessions: shouldAutoResumeAgent, allowLauncherScript: true ) - let effectiveResumeBinding = restoredBindingInput == nil ? nil : resumeBinding + let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding let workingDirectory = effectiveResumeBinding?.cwd ?? snapshot.terminal?.workingDirectory @@ -1125,7 +1188,7 @@ extension Workspace { ?? snapshot.directory ?? currentDirectory let localWorkingDirectory = remoteTerminalStartupCommand() == nil ? workingDirectory : nil - let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingInput == nil + let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) : nil let restoredTmuxStartupScript = restorableTmuxStartCommand.flatMap { @@ -1138,21 +1201,28 @@ extension Workspace { let shouldReplayScrollback = Self.shouldReplaySessionScrollback( restorableAgent: restorableAgent, tmuxStartCommand: restoredTmuxStartCommand, - resumeStartupInput: restoredBindingInput + resumeStartupInput: restoredBindingLaunch?.hasStartupWork == true ? "" : nil ) - let restoredAgentResumeInput = shouldAutoResumeAgent && restoredHibernation == nil - ? (restoredBindingInput == nil ? restorableAgent?.resumeStartupInput() : nil) + let restoredAgentResumeCommand = shouldAutoResumeAgent && restoredHibernation == nil + ? (restoredBindingLaunch == nil ? restorableAgent?.resumeStartupCommand() : nil) : nil - let restoredStartupInput = restoredBindingInput ?? restoredAgentResumeInput - let restoredAgentWillRunStartupInput = restorableAgent != nil && ( - restoredAgentResumeInput != nil || - (restoredBindingInput != nil && resumeBinding?.isAgentHookBinding == true) - ) // Snapshot session IDs belong to the previous app run's remote daemon. // Restored persistent SSH terminals start a fresh attach path and replay // local scrollback until the new remote PTY is ready. let restoredRemotePTYSessionID: String? = nil let restoredRemotePTYAttachCommand: String? = nil + let restoredStartupCommand = + restoredRemotePTYAttachCommand + ?? restoredTmuxStartupScript?.path + ?? restoredBindingLaunch?.initialCommand + ?? restoredAgentResumeCommand + let restoredStartupInput = restoredRemotePTYAttachCommand == nil + ? restoredBindingLaunch?.initialInput + : nil + let restoredAgentWillRunStartupLaunch = restorableAgent != nil && ( + restoredAgentResumeCommand != nil || + (restoredBindingLaunch?.hasStartupWork == true && resumeBinding?.isAgentHookBinding == true) + ) #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) @@ -1161,7 +1231,7 @@ extension Workspace { "session.restore.agent panel=\(snapshot.id.uuidString.prefix(5)) " + "kind=\(restorableAgent.kind.rawValue) session=\(sessionPreview) " + "hasLaunch=\(restorableAgent.launchCommand == nil ? 0 : 1) " + - "launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeInput == nil ? 0 : 1) " + + "launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeCommand == nil ? 0 : 1) " + "autoResume=\(autoResumeAgentSessions ? 1 : 0) " + "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" ) @@ -1170,7 +1240,7 @@ extension Workspace { cmuxDebugLog( "session.restore.surfaceResume panel=\(snapshot.id.uuidString.prefix(5)) " + "kind=\(resumeBinding.kind ?? "unknown") source=\(resumeBinding.source ?? "unknown") " + - "hasInput=\(restoredBindingInput == nil ? 0 : 1) " + + "hasLaunch=\(restoredBindingLaunch?.hasStartupWork == true ? 1 : 0) " + "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" ) } @@ -1182,9 +1252,9 @@ extension Workspace { inPane: paneId, focus: false, workingDirectory: localWorkingDirectory, - initialCommand: restoredRemotePTYAttachCommand ?? restoredTmuxStartupScript?.path, + initialCommand: restoredStartupCommand, tmuxStartCommand: restoredTmuxStartCommand, - initialInput: restoredRemotePTYAttachCommand == nil ? restoredStartupInput : nil, + initialInput: restoredStartupInput, startupEnvironment: replayEnvironment, remotePTYSessionID: restoredRemotePTYSessionID ) else { @@ -1203,7 +1273,7 @@ extension Workspace { } if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent - if restoredAgentWillRunStartupInput { + if restoredAgentWillRunStartupLaunch { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .awaitingAutoResumeCommand } else { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .manualResumeAvailable From 2de2f75df97273bcbe5c827da97a0df153a81825 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 04:25:33 -0700 Subject: [PATCH 03/10] Tighten restored startup launch state --- Sources/Workspace.swift | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f0b46aad86bf..cb98522fc1f0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -517,7 +517,7 @@ extension Workspace { ) && Self.shouldReplaySessionScrollback( restorableAgent: effectiveRestorableAgent, tmuxStartCommand: restorableTmuxStartCommand, - resumeStartupInput: resumeStartupInput + hasResumeStartupWork: resumeStartupInput != nil ) #if DEBUG let allowDebugFallbackScrollback = debugSessionSnapshotScrollbackFallbackPanelIds.contains(panelId) @@ -785,12 +785,12 @@ extension Workspace { nonisolated static func shouldReplaySessionScrollback( restorableAgent: SessionRestorableAgentSnapshot?, tmuxStartCommand: String? = nil, - resumeStartupInput: String? = nil + hasResumeStartupWork: Bool = false ) -> Bool { // Agent restores relaunch from the provider's session ID. Replaying the - // old TUI scrollback can print stale launch commands and race the resume input. + // old TUI scrollback can print stale launch commands and race resume startup work. // OMX HUD panes restore from their tmux start command for the same reason. - restorableAgent == nil && restorableTmuxStartCommand(tmuxStartCommand) == nil && resumeStartupInput == nil + restorableAgent == nil && restorableTmuxStartCommand(tmuxStartCommand) == nil && !hasResumeStartupWork } nonisolated static func shouldAutoConnectRestoredRemote( @@ -805,12 +805,22 @@ extension Workspace { return !snapshot.panels.contains { $0.terminal != nil } } - nonisolated struct SurfaceResumeStartupLaunch { - var initialCommand: String? - var initialInput: String? + nonisolated enum SurfaceResumeStartupLaunch { + case command(String) + case input(String) - var hasStartupWork: Bool { - initialCommand != nil || initialInput != nil + var initialCommand: String? { + if case .command(let command) = self { + return command + } + return nil + } + + var initialInput: String? { + if case .input(let input) = self { + return input + } + return nil } } @@ -859,14 +869,14 @@ extension Workspace { fileManager: fileManager, temporaryDirectory: temporaryDirectory ) { - return SurfaceResumeStartupLaunch(initialCommand: command, initialInput: nil) + return .command(command) } guard let input = effectiveBinding.startupInputWithLauncherScript( allowLauncherScript: allowLauncherScript ) else { return nil } - return SurfaceResumeStartupLaunch(initialCommand: nil, initialInput: input) + return .input(input) } nonisolated private static func approvedSurfaceResumeBinding( @@ -1201,7 +1211,7 @@ extension Workspace { let shouldReplayScrollback = Self.shouldReplaySessionScrollback( restorableAgent: restorableAgent, tmuxStartCommand: restoredTmuxStartCommand, - resumeStartupInput: restoredBindingLaunch?.hasStartupWork == true ? "" : nil + hasResumeStartupWork: restoredBindingLaunch != nil ) let restoredAgentResumeCommand = shouldAutoResumeAgent && restoredHibernation == nil ? (restoredBindingLaunch == nil ? restorableAgent?.resumeStartupCommand() : nil) @@ -1221,7 +1231,7 @@ extension Workspace { : nil let restoredAgentWillRunStartupLaunch = restorableAgent != nil && ( restoredAgentResumeCommand != nil || - (restoredBindingLaunch?.hasStartupWork == true && resumeBinding?.isAgentHookBinding == true) + (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) ) #if DEBUG if let restorableAgent { @@ -1240,7 +1250,7 @@ extension Workspace { cmuxDebugLog( "session.restore.surfaceResume panel=\(snapshot.id.uuidString.prefix(5)) " + "kind=\(resumeBinding.kind ?? "unknown") source=\(resumeBinding.source ?? "unknown") " + - "hasLaunch=\(restoredBindingLaunch?.hasStartupWork == true ? 1 : 0) " + + "hasLaunch=\(restoredBindingLaunch == nil ? 0 : 1) " + "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" ) } From 46b5f21217a0c0fb0aa797c1a685c041cb682a40 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 05:55:07 -0700 Subject: [PATCH 04/10] Preserve remote startup for agent restore --- Sources/RestorableAgentSession.swift | 6 ++- Sources/Workspace.swift | 46 +++++++++++------ .../AgentSessionAutoResumeSettingsTests.swift | 49 +++++++++++++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 453b31d3dd58..47cf5c614697 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -579,12 +579,14 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { func resumeStartupInput( fileManager: FileManager = .default, - temporaryDirectory: URL = FileManager.default.temporaryDirectory + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + allowLauncherScript: Bool = true ) -> String? { startupInput( command: resumeCommand, fileManager: fileManager, - temporaryDirectory: temporaryDirectory + temporaryDirectory: temporaryDirectory, + allowLauncherScript: allowLauncherScript ) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cb98522fc1f0..91e2b34ab2e6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1185,11 +1185,20 @@ extension Workspace { (resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true) ? nil : resumeBinding - let restoredBindingLaunch = Self.surfaceResumeStartupLaunch( - resumeBindingForStartup, - autoResumeAgentSessions: shouldAutoResumeAgent, - allowLauncherScript: true - ) + let remoteStartupCommand = remoteTerminalStartupCommand() + let restoredBindingLaunch: SurfaceResumeStartupLaunch? = if remoteStartupCommand != nil { + Self.surfaceResumeStartupInput( + resumeBindingForStartup, + autoResumeAgentSessions: shouldAutoResumeAgent, + allowLauncherScript: false + ).map(SurfaceResumeStartupLaunch.input) + } else { + Self.surfaceResumeStartupLaunch( + resumeBindingForStartup, + autoResumeAgentSessions: shouldAutoResumeAgent, + allowLauncherScript: true + ) + } let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding let workingDirectory = effectiveResumeBinding?.cwd @@ -1197,7 +1206,7 @@ extension Workspace { ?? restorableAgent?.workingDirectory ?? snapshot.directory ?? currentDirectory - let localWorkingDirectory = remoteTerminalStartupCommand() == nil ? workingDirectory : nil + let localWorkingDirectory = remoteStartupCommand == nil ? workingDirectory : nil let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) : nil @@ -1208,14 +1217,23 @@ extension Workspace { ) } let restoredTmuxStartCommand = restoredTmuxStartupScript == nil ? nil : restorableTmuxStartCommand + let restoredAgentResumeLaunch: SurfaceResumeStartupLaunch? = + if shouldAutoResumeAgent && restoredHibernation == nil && restoredBindingLaunch == nil { + if remoteStartupCommand != nil { + restorableAgent?.resumeStartupInput(allowLauncherScript: false) + .map(SurfaceResumeStartupLaunch.input) + } else { + restorableAgent?.resumeStartupCommand() + .map(SurfaceResumeStartupLaunch.command) + } + } else { + nil + } let shouldReplayScrollback = Self.shouldReplaySessionScrollback( restorableAgent: restorableAgent, tmuxStartCommand: restoredTmuxStartCommand, - hasResumeStartupWork: restoredBindingLaunch != nil + hasResumeStartupWork: restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil ) - let restoredAgentResumeCommand = shouldAutoResumeAgent && restoredHibernation == nil - ? (restoredBindingLaunch == nil ? restorableAgent?.resumeStartupCommand() : nil) - : nil // Snapshot session IDs belong to the previous app run's remote daemon. // Restored persistent SSH terminals start a fresh attach path and replay // local scrollback until the new remote PTY is ready. @@ -1225,12 +1243,12 @@ extension Workspace { restoredRemotePTYAttachCommand ?? restoredTmuxStartupScript?.path ?? restoredBindingLaunch?.initialCommand - ?? restoredAgentResumeCommand + ?? restoredAgentResumeLaunch?.initialCommand let restoredStartupInput = restoredRemotePTYAttachCommand == nil - ? restoredBindingLaunch?.initialInput + ? (restoredBindingLaunch?.initialInput ?? restoredAgentResumeLaunch?.initialInput) : nil let restoredAgentWillRunStartupLaunch = restorableAgent != nil && ( - restoredAgentResumeCommand != nil || + restoredAgentResumeLaunch != nil || (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) ) #if DEBUG @@ -1241,7 +1259,7 @@ extension Workspace { "session.restore.agent panel=\(snapshot.id.uuidString.prefix(5)) " + "kind=\(restorableAgent.kind.rawValue) session=\(sessionPreview) " + "hasLaunch=\(restorableAgent.launchCommand == nil ? 0 : 1) " + - "launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeCommand == nil ? 0 : 1) " + + "launchArgc=\(launchArgc) hasResume=\(restoredAgentResumeLaunch == nil ? 0 : 1) " + "autoResume=\(autoResumeAgentSessions ? 1 : 0) " + "replayScrollback=\(shouldReplayScrollback ? 1 : 0)" ) diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 6f5f5f8d9967..01bcd076c7b4 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -193,6 +193,55 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } } + @MainActor + func testRemoteWorkspaceAutoResumeKeepsRemoteStartupCommand() throws { + try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) // autoResumeAgentSessions = true (default) + + let source = Workspace() + let remoteCommand = "ssh cmux-macmini" + source.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-auto-resume-remote", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-auto-resume-remote.sock", + terminalStartupCommand: remoteCommand + ), + autoConnect: false + ) + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: "codex-remote-running-session" + ) + source.updatePanelShellActivityState(panelId: sourcePanelId, state: .commandRunning) + let snapshot = source.sessionSnapshot(includeScrollback: false, restorableAgentIndex: sourceIndex) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let restoredInput = restoredPanel.surface.debugInitialInputMetadata() + + XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), remoteCommand) + XCTAssertTrue(restoredInput.hasInitialInput) + XCTAssertGreaterThan(restoredInput.byteCount, 0) + let input = try XCTUnwrap(restoredPanel.surface.initialInput) + XCTAssertTrue(input.contains("'resume'"), input) + XCTAssertTrue(input.contains("codex-remote-running-session"), input) + XCTAssertFalse(input.contains("cmux-agent-resume"), input) + XCTAssertNil(restoredPanel.requestedWorkingDirectory) + } + } + @MainActor func testUnknownAgentShellStatePreservesLegacyAutoResumeBehavior() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { From cda9159da38f65bf8b902e6c9180a7ecb25e93d1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 06:44:06 -0700 Subject: [PATCH 05/10] Relax remote startup restore assertion --- cmuxTests/AgentSessionAutoResumeSettingsTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 01bcd076c7b4..482800b55f0a 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -230,8 +230,10 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) let restoredInput = restoredPanel.surface.debugInitialInputMetadata() + let restoredRemoteCommand = try XCTUnwrap(restored.remoteConfiguration?.terminalStartupCommand) - XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), remoteCommand) + XCTAssertTrue(restoredRemoteCommand.contains("cmux-macmini"), restoredRemoteCommand) + XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), restoredRemoteCommand) XCTAssertTrue(restoredInput.hasInitialInput) XCTAssertGreaterThan(restoredInput.byteCount, 0) let input = try XCTUnwrap(restoredPanel.surface.initialInput) From c521df9df8c744753e045865e1fe0c85cb10b294 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 07:54:32 -0700 Subject: [PATCH 06/10] Fix startup restore lifecycle --- Sources/RestorableAgentSession.swift | 2 +- Sources/SessionPersistence.swift | 27 +++++++++++++++---- Sources/Workspace.swift | 16 ++++++++--- .../AgentSessionAutoResumeSettingsTests.swift | 23 +++++++++++----- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 47cf5c614697..a457633dc388 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -691,7 +691,7 @@ private enum AgentResumeScriptStore { command ] if returnToLoginShell { - lines.append("exec \"${SHELL:-/bin/zsh}\" -l") + lines.append(contentsOf: TerminalStartupReturnShellScript.lines) } let contents = lines.joined(separator: "\n") + "\n" try contents.write(to: scriptURL, atomically: true, encoding: .utf8) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index d4c987e2ae93..42da17d7ab8d 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1212,6 +1212,21 @@ enum SurfaceResumeApprovalStore { #endif } +nonisolated enum TerminalStartupReturnShellScript { + static let lines = [ + #"_cmux_resume_shell="${SHELL:-/bin/zsh}""#, + #"if [[ "${_cmux_resume_shell:t}" == "zsh" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" && -r "${CMUX_SHELL_INTEGRATION_DIR}/.zshenv" ]]; then"#, + #" if [[ -n "${ZDOTDIR+X}" ]]; then"#, + #" export CMUX_ZSH_ZDOTDIR="$ZDOTDIR""#, + #" else"#, + #" unset CMUX_ZSH_ZDOTDIR"#, + #" fi"#, + #" export ZDOTDIR="$CMUX_SHELL_INTEGRATION_DIR""#, + #"fi"#, + #"exec "$_cmux_resume_shell" -l"# + ] +} + private enum SurfaceResumeBindingScriptStore { private static let directoryName = "cmux-surface-resume" private static let scriptTTL: TimeInterval = 24 * 60 * 60 @@ -1234,13 +1249,15 @@ private enum SurfaceResumeBindingScriptStore { "\(prefix)-\(UUID().uuidString).zsh", isDirectory: false ) - var contents = "#!/bin/zsh\nrm -f -- \"$0\" 2>/dev/null || true\n\(inlineInput)" + var lines = [ + "#!/bin/zsh", + "rm -f -- \"$0\" 2>/dev/null || true", + inlineInput + ] if returnToLoginShell { - if !contents.hasSuffix("\n") { - contents.append("\n") - } - contents.append("exec \"${SHELL:-/bin/zsh}\" -l\n") + lines.append(contentsOf: TerminalStartupReturnShellScript.lines) } + let contents = lines.joined(separator: "\n") + "\n" try contents.write(to: scriptURL, atomically: true, encoding: .utf8) try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) return scriptURL diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 91e2b34ab2e6..99d7088ebc6d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1247,10 +1247,16 @@ extension Workspace { let restoredStartupInput = restoredRemotePTYAttachCommand == nil ? (restoredBindingLaunch?.initialInput ?? restoredAgentResumeLaunch?.initialInput) : nil - let restoredAgentWillRunStartupLaunch = restorableAgent != nil && ( - restoredAgentResumeLaunch != nil || - (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) + let restoredAgentWillRunStartupCommand = restorableAgent != nil && ( + restoredAgentResumeLaunch?.initialCommand != nil || + (restoredBindingLaunch?.initialCommand != nil && resumeBinding?.isAgentHookBinding == true) ) + let restoredAgentWillRunStartupInput = restorableAgent != nil && ( + restoredAgentResumeLaunch?.initialInput != nil || + (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) + ) + let restoredAgentWillRunStartupLaunch = + restoredAgentWillRunStartupCommand || restoredAgentWillRunStartupInput #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) @@ -1301,7 +1307,9 @@ extension Workspace { } if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent - if restoredAgentWillRunStartupLaunch { + if restoredAgentWillRunStartupCommand { + restoredAgentResumeStatesByPanelId[terminalPanel.id] = .autoResumeCommandRunning + } else if restoredAgentWillRunStartupInput { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .awaitingAutoResumeCommand } else { restoredAgentResumeStatesByPanelId[terminalPanel.id] = .manualResumeAvailable diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 482800b55f0a..95229d55cd57 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -190,6 +190,13 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { restoredPanel, scriptContains: ["'resume'", "codex-running-at-snapshot-session"] ) + XCTAssertEqual( + restored.restoredAgentResumeStatesByPanelId[restoredPanelId], + .autoResumeCommandRunning + ) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) + XCTAssertNil(restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) } } @@ -241,6 +248,10 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertTrue(input.contains("codex-remote-running-session"), input) XCTAssertFalse(input.contains("cmux-agent-resume"), input) XCTAssertNil(restoredPanel.requestedWorkingDirectory) + XCTAssertEqual( + restored.restoredAgentResumeStatesByPanelId[restoredPanelId], + .awaitingAutoResumeCommand + ) } } @@ -445,7 +456,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } @MainActor - func testAgentHookResumeBindingKeepsRestoredAgentPendingDuringStartupCommand() throws { + func testAgentHookResumeBindingClearsAfterStartupCommandCompletes() throws { let defaults = UserDefaults.standard let key = AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey let previous = defaults.object(forKey: key) @@ -494,11 +505,9 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { restoredPanel, scriptContains: ["codex resume codex-binding-auto-resume-session"] ) - - restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) XCTAssertEqual( - restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, - "codex-binding-auto-resume-session" + restored.restoredAgentResumeStatesByPanelId[restoredPanelId], + .autoResumeCommandRunning ) restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) @@ -590,7 +599,9 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { for needle in needles { XCTAssertTrue(script.contains(needle), script, file: file, line: line) } - XCTAssertTrue(script.contains("exec \"${SHELL:-/bin/zsh}\" -l"), script, file: file, line: line) + XCTAssertTrue(script.contains("CMUX_SHELL_INTEGRATION_DIR"), script, file: file, line: line) + XCTAssertTrue(script.contains("CMUX_ZSH_ZDOTDIR"), script, file: file, line: line) + XCTAssertTrue(script.contains("exec \"$_cmux_resume_shell\" -l"), script, file: file, line: line) } private func makeRestorableAgentIndex( From c673bed1b053dec92ed9e9da597affea20c17503 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 08:02:47 -0700 Subject: [PATCH 07/10] Remove unused restore launch flag --- Sources/Workspace.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 99d7088ebc6d..c437c28196fc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1255,8 +1255,6 @@ extension Workspace { restoredAgentResumeLaunch?.initialInput != nil || (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) ) - let restoredAgentWillRunStartupLaunch = - restoredAgentWillRunStartupCommand || restoredAgentWillRunStartupInput #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) From f9aca65c578410fe6f8f38b9a8e332761c238957 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 09:10:37 -0700 Subject: [PATCH 08/10] Run startup resumes in login shell --- Sources/RestorableAgentSession.swift | 19 ++++-- Sources/SessionPersistence.swift | 27 +++++++-- Sources/Workspace.swift | 5 +- .../AgentSessionAutoResumeSettingsTests.swift | 59 ++++++++++++++++++- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index a457633dc388..36feebe00308 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -580,13 +580,15 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { func resumeStartupInput( fileManager: FileManager = .default, temporaryDirectory: URL = FileManager.default.temporaryDirectory, - allowLauncherScript: Bool = true + allowLauncherScript: Bool = true, + allowOversizedInlineInput: Bool = false ) -> String? { startupInput( command: resumeCommand, fileManager: fileManager, temporaryDirectory: temporaryDirectory, - allowLauncherScript: allowLauncherScript + allowLauncherScript: allowLauncherScript, + allowOversizedInlineInput: allowOversizedInlineInput ) } @@ -625,13 +627,17 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { command: String?, fileManager: FileManager, temporaryDirectory: URL, - allowLauncherScript: Bool = true + allowLauncherScript: Bool = true, + allowOversizedInlineInput: Bool = false ) -> String? { guard let command else { return nil } let inlineInput = command + "\n" guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else { return inlineInput } + guard !allowOversizedInlineInput else { + return inlineInput + } guard allowLauncherScript else { return nil } guard let scriptURL = AgentResumeScriptStore.writeLauncherScript( command: command, @@ -687,11 +693,12 @@ private enum AgentResumeScriptStore { ) var lines = [ "#!/bin/zsh", - "rm -f -- \"$0\" 2>/dev/null || true", - command + "rm -f -- \"$0\" 2>/dev/null || true" ] if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.lines) + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(command: command)) + } else { + lines.append(command) } let contents = lines.joined(separator: "\n") + "\n" try contents.write(to: scriptURL, atomically: true, encoding: .utf8) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 42da17d7ab8d..bb5c6f2957ed 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1213,8 +1213,8 @@ enum SurfaceResumeApprovalStore { } nonisolated enum TerminalStartupReturnShellScript { - static let lines = [ - #"_cmux_resume_shell="${SHELL:-/bin/zsh}""#, + private static let shellLine = #"_cmux_resume_shell="${SHELL:-/bin/zsh}""# + private static let zshIntegrationReentryLines = [ #"if [[ "${_cmux_resume_shell:t}" == "zsh" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" && -r "${CMUX_SHELL_INTEGRATION_DIR}/.zshenv" ]]; then"#, #" if [[ -n "${ZDOTDIR+X}" ]]; then"#, #" export CMUX_ZSH_ZDOTDIR="$ZDOTDIR""#, @@ -1223,8 +1223,24 @@ nonisolated enum TerminalStartupReturnShellScript { #" fi"#, #" export ZDOTDIR="$CMUX_SHELL_INTEGRATION_DIR""#, #"fi"#, + ] + + static let lines = [shellLine] + zshIntegrationReentryLines + [ #"exec "$_cmux_resume_shell" -l"# ] + + static func commandThenReturnLines(command: String) -> [String] { + let quotedCommand = TerminalStartupShellQuoting.singleQuoted(command) + return [ + shellLine, + #"case "${_cmux_resume_shell:t}" in"#, + #" zsh|bash) "$_cmux_resume_shell" -lic \#(quotedCommand) ;;"#, + #" *) "$_cmux_resume_shell" -lc \#(quotedCommand) ;;"#, + #"esac"#, + ] + zshIntegrationReentryLines + [ + #"exec "$_cmux_resume_shell" -l"# + ] + } } private enum SurfaceResumeBindingScriptStore { @@ -1251,11 +1267,12 @@ private enum SurfaceResumeBindingScriptStore { ) var lines = [ "#!/bin/zsh", - "rm -f -- \"$0\" 2>/dev/null || true", - inlineInput + "rm -f -- \"$0\" 2>/dev/null || true" ] if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.lines) + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(command: inlineInput)) + } else { + lines.append(inlineInput) } let contents = lines.joined(separator: "\n") + "\n" try contents.write(to: scriptURL, atomically: true, encoding: .utf8) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c437c28196fc..364d5d407a72 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1220,7 +1220,10 @@ extension Workspace { let restoredAgentResumeLaunch: SurfaceResumeStartupLaunch? = if shouldAutoResumeAgent && restoredHibernation == nil && restoredBindingLaunch == nil { if remoteStartupCommand != nil { - restorableAgent?.resumeStartupInput(allowLauncherScript: false) + restorableAgent?.resumeStartupInput( + allowLauncherScript: false, + allowOversizedInlineInput: true + ) .map(SurfaceResumeStartupLaunch.input) } else { restorableAgent?.resumeStartupCommand() diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 95229d55cd57..d40cec55d500 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -255,6 +255,59 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } } + @MainActor + func testRemoteWorkspaceAutoResumeKeepsLongResumeInputInline() throws { + try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) // autoResumeAgentSessions = true (default) + + let source = Workspace() + let remoteCommand = "ssh cmux-macmini" + source.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-auto-resume-long-remote", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-auto-resume-long-remote.sock", + terminalStartupCommand: remoteCommand + ), + autoConnect: false + ) + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let longPath = "/Users/cmux/" + String(repeating: "nested-project-", count: 120) + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: "codex-remote-long-running-session", + extraArguments: ["--add-dir", longPath] + ) + source.updatePanelShellActivityState(panelId: sourcePanelId, state: .commandRunning) + let snapshot = source.sessionSnapshot(includeScrollback: false, restorableAgentIndex: sourceIndex) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.terminalPanel(for: restoredPanelId)) + let restoredInput = try XCTUnwrap(restoredPanel.surface.initialInput) + + XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), restored.remoteConfiguration?.terminalStartupCommand) + XCTAssertGreaterThan(restoredInput.utf8.count, SessionRestorableAgentSnapshot.maxInlineStartupInputBytes) + XCTAssertTrue(restoredInput.contains("'resume'"), restoredInput) + XCTAssertTrue(restoredInput.contains("codex-remote-long-running-session"), restoredInput) + XCTAssertTrue(restoredInput.contains(longPath), restoredInput) + XCTAssertFalse(restoredInput.contains("cmux-agent-resume"), restoredInput) + XCTAssertEqual( + restored.restoredAgentResumeStatesByPanelId[restoredPanelId], + .awaitingAutoResumeCommand + ) + } + } + @MainActor func testUnknownAgentShellStatePreservesLegacyAutoResumeBehavior() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { @@ -601,13 +654,15 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } XCTAssertTrue(script.contains("CMUX_SHELL_INTEGRATION_DIR"), script, file: file, line: line) XCTAssertTrue(script.contains("CMUX_ZSH_ZDOTDIR"), script, file: file, line: line) + XCTAssertTrue(script.contains("\"$_cmux_resume_shell\" -lic"), script, file: file, line: line) XCTAssertTrue(script.contains("exec \"$_cmux_resume_shell\" -l"), script, file: file, line: line) } private func makeRestorableAgentIndex( workspaceId: UUID, panelId: UUID, - sessionId: String + sessionId: String, + extraArguments: [String] = [] ) throws -> RestorableAgentSessionIndex { let home = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-auto-resume-\(UUID().uuidString)", isDirectory: true) @@ -636,7 +691,7 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { "launchCommand": [ "launcher": "codex", "executablePath": "/usr/local/bin/codex", - "arguments": ["/usr/local/bin/codex", "--model", "gpt-5.4"], + "arguments": ["/usr/local/bin/codex", "--model", "gpt-5.4"] + extraArguments, "workingDirectory": "/tmp/repo", "environment": ["CODEX_HOME": "/tmp/codex"], "capturedAt": Date().timeIntervalSince1970, From ad5efedab4a3b17de4eab6de02cc74cfe21050f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 10:24:17 -0700 Subject: [PATCH 09/10] Tighten remote restore assertion --- Sources/SessionPersistence.swift | 4 ---- cmuxTests/AgentSessionAutoResumeSettingsTests.swift | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index bb5c6f2957ed..db9a96988f79 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1225,10 +1225,6 @@ nonisolated enum TerminalStartupReturnShellScript { #"fi"#, ] - static let lines = [shellLine] + zshIntegrationReentryLines + [ - #"exec "$_cmux_resume_shell" -l"# - ] - static func commandThenReturnLines(command: String) -> [String] { let quotedCommand = TerminalStartupShellQuoting.singleQuoted(command) return [ diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index d40cec55d500..749428e737db 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -239,8 +239,8 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { let restoredInput = restoredPanel.surface.debugInitialInputMetadata() let restoredRemoteCommand = try XCTUnwrap(restored.remoteConfiguration?.terminalStartupCommand) - XCTAssertTrue(restoredRemoteCommand.contains("cmux-macmini"), restoredRemoteCommand) - XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), restoredRemoteCommand) + XCTAssertEqual(restoredRemoteCommand, remoteCommand) + XCTAssertEqual(restoredPanel.surface.debugInitialCommand(), remoteCommand) XCTAssertTrue(restoredInput.hasInitialInput) XCTAssertGreaterThan(restoredInput.byteCount, 0) let input = try XCTUnwrap(restoredPanel.surface.initialInput) From dc6dd63bcdd6ae499f012ae00eef0a541af667f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 21:33:55 -0700 Subject: [PATCH 10/10] Handle csh agent restore launchers --- Sources/SessionPersistence.swift | 5 +++-- cmuxTests/AgentSessionAutoResumeSettingsTests.swift | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f839c87e83fd..6fdb5377d292 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1231,10 +1231,11 @@ nonisolated enum TerminalStartupReturnShellScript { shellLine, #"case "${_cmux_resume_shell:t}" in"#, #" zsh|bash) "$_cmux_resume_shell" -lic \#(quotedCommand) ;;"#, - #" *) "$_cmux_resume_shell" -lc \#(quotedCommand) ;;"#, + #" csh|tcsh) "$_cmux_resume_shell" -c \#(quotedCommand) ;;"#, + #" *) "$_cmux_resume_shell" -c \#(quotedCommand) ;;"#, #"esac"#, ] + zshIntegrationReentryLines + [ - #"exec "$_cmux_resume_shell" -l"# + #"exec -l "$_cmux_resume_shell""# ] } } diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 749428e737db..7ddaf6d2899c 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -655,7 +655,8 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertTrue(script.contains("CMUX_SHELL_INTEGRATION_DIR"), script, file: file, line: line) XCTAssertTrue(script.contains("CMUX_ZSH_ZDOTDIR"), script, file: file, line: line) XCTAssertTrue(script.contains("\"$_cmux_resume_shell\" -lic"), script, file: file, line: line) - XCTAssertTrue(script.contains("exec \"$_cmux_resume_shell\" -l"), script, file: file, line: line) + XCTAssertTrue(script.contains("csh|tcsh) \"$_cmux_resume_shell\" -c"), script, file: file, line: line) + XCTAssertTrue(script.contains("exec -l \"$_cmux_resume_shell\""), script, file: file, line: line) } private func makeRestorableAgentIndex(