Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ fileprivate func shellSingleQuoted(_ value: String) -> String {
TerminalStartupShellQuoting.singleQuoted(value)
}

func normalizedTerminalWorkingDirectory(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}

func firstNormalizedTerminalWorkingDirectory(_ values: [String?]) -> String? {
for value in values {
if let normalized = normalizedTerminalWorkingDirectory(value) {
return normalized
}
}
return nil
}

nonisolated enum TerminalStartupWorkingDirectoryPrefix {
static func optionalChangeDirectoryPrefix(for workingDirectory: String?) -> String? {
guard let workingDirectory = normalized(workingDirectory) else { return nil }
Expand Down Expand Up @@ -100,11 +117,7 @@ nonisolated enum TerminalStartupWorkingDirectoryPrefix {
}

private static func normalized(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
normalizedTerminalWorkingDirectory(value)
}

private static func legacySingleQuoted(_ value: String) -> String {
Expand Down Expand Up @@ -712,8 +725,13 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {

func resumeStartupCommand(
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
returnWorkingDirectories: [String?] = []
) -> String? {
let resumeCommandWorkingDirectory = firstNormalizedTerminalWorkingDirectory([
workingDirectory,
launchCommand?.workingDirectory,
])
guard let command = resumeCommand,
let scriptURL = AgentResumeScriptStore.writeLauncherScript(
command: command,
Expand All @@ -722,11 +740,16 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {
fileManager: fileManager,
temporaryDirectory: temporaryDirectory,
returnToLoginShell: true,
// Match the resume command's own cd: agents with an `.ignore` cwd policy resume from
// the current directory (no cd), so the post-exit shell must not force the launch dir.
// Match only the resume command's own cd: `.ignore` agents resume from the
// current directory, but the outer post-exit shell still returns to the saved
// session directory below.
workingDirectory: registration?.cwd == .ignore
? nil
: (workingDirectory ?? launchCommand?.workingDirectory)
: resumeCommandWorkingDirectory,
returnWorkingDirectories: returnWorkingDirectories + [
workingDirectory,
launchCommand?.workingDirectory,
]
) else {
return nil
}
Expand Down Expand Up @@ -798,7 +821,8 @@ private enum AgentResumeScriptStore {
fileManager: FileManager,
temporaryDirectory: URL,
returnToLoginShell: Bool = false,
workingDirectory: String? = nil
workingDirectory: String? = nil,
returnWorkingDirectories: [String?] = []
) -> URL? {
let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true)
do {
Expand All @@ -822,7 +846,9 @@ private enum AgentResumeScriptStore {
if returnToLoginShell {
lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(
command: command,
workingDirectory: workingDirectory
returnWorkingDirectories: returnWorkingDirectories.isEmpty
? [workingDirectory]
: returnWorkingDirectories
))
} else {
lines.append(command)
Expand Down
42 changes: 31 additions & 11 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,17 @@ nonisolated struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable {

func startupCommandWithLauncherScript(
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
returnWorkingDirectories: [String?] = []
) -> String? {
guard let inlineInput = inlineStartupInput,
let scriptURL = SurfaceResumeBindingScriptStore.writeLauncherScript(
inlineInput: inlineInput,
binding: self,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory,
returnToLoginShell: true
returnToLoginShell: true,
returnWorkingDirectories: returnWorkingDirectories
) else {
return nil
}
Expand Down Expand Up @@ -1280,7 +1282,10 @@ nonisolated enum TerminalStartupReturnShellScript {
#"fi"#,
]

static func commandThenReturnLines(command: String, workingDirectory: String? = nil) -> [String] {
static func commandThenReturnLines(
command: String,
returnWorkingDirectories: [String?] = []
) -> [String] {
let quotedCommand = TerminalStartupShellQuoting.singleQuoted(command)
var lines = [
shellLine,
Expand All @@ -1292,15 +1297,29 @@ nonisolated enum TerminalStartupReturnShellScript {
] + zshIntegrationReentryLines
// The resume command's `cd` runs inside the child shell above, so after the resumed agent
// exits the outer login shell would otherwise land in this script's launch cwd (the surface
// default), not the session's directory. Return the outer shell to the session's working
// directory so killing a resumed agent leaves you where the session lived.
if let workingDirectory, !workingDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let quotedDirectory = TerminalStartupShellQuoting.singleQuoted(workingDirectory)
lines.append(#"{ cd -- \#(quotedDirectory) 2>/dev/null || true; }"#)
}
// default), not the session's directory. Return the outer shell through the saved cwd
// candidates in order, then HOME.
lines.append(returnWorkingDirectoryLine(returnWorkingDirectories: returnWorkingDirectories))
lines.append(#"exec -l "$_cmux_resume_shell""#)
return lines
}

private static func returnWorkingDirectoryLine(returnWorkingDirectories: [String?]) -> String {
var directories: [String] = []
for candidate in returnWorkingDirectories.map(normalized) {
guard let candidate, !directories.contains(candidate) else { continue }
directories.append(candidate)
}

let commands = directories.map { directory in
"cd -- \(TerminalStartupShellQuoting.singleQuoted(directory)) 2>/dev/null"
} + [#"cd -- "${HOME}" 2>/dev/null"#]
return "{ \(commands.joined(separator: " || ")) || true; }"
Comment thread
cursor[bot] marked this conversation as resolved.
}

private static func normalized(_ value: String?) -> String? {
normalizedTerminalWorkingDirectory(value)
}
}

private enum SurfaceResumeBindingScriptStore {
Expand All @@ -1312,7 +1331,8 @@ private enum SurfaceResumeBindingScriptStore {
binding: SurfaceResumeBindingSnapshot,
fileManager: FileManager,
temporaryDirectory: URL,
returnToLoginShell: Bool = false
returnToLoginShell: Bool = false,
returnWorkingDirectories: [String?] = []
) -> URL? {
let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true)
do {
Expand All @@ -1332,7 +1352,7 @@ private enum SurfaceResumeBindingScriptStore {
if returnToLoginShell {
lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(
command: inlineInput,
workingDirectory: binding.cwd
returnWorkingDirectories: [binding.cwd] + returnWorkingDirectories
))
} else {
lines.append(inlineInput)
Expand Down
39 changes: 28 additions & 11 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,8 @@ extension Workspace {
approvalStoreURL: URL = SurfaceResumeApprovalStore.defaultURL(),
approvalSigningSecret: Data? = nil,
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
returnWorkingDirectories: [String?] = []
) -> SurfaceResumeStartupLaunch? {
guard let effectiveBinding = approvedSurfaceResumeBinding(
resumeBinding,
Expand All @@ -966,21 +967,24 @@ extension Workspace {
forApprovedBinding: effectiveBinding,
allowLauncherScript: allowLauncherScript,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory
temporaryDirectory: temporaryDirectory,
returnWorkingDirectories: returnWorkingDirectories
)
}

nonisolated private static func surfaceResumeStartupLaunch(
forApprovedBinding effectiveBinding: SurfaceResumeBindingSnapshot,
allowLauncherScript: Bool = true,
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
returnWorkingDirectories: [String?] = []
) -> SurfaceResumeStartupLaunch? {
if effectiveBinding.isAgentHookBinding,
allowLauncherScript,
let command = effectiveBinding.startupCommandWithLauncherScript(
fileManager: fileManager,
temporaryDirectory: temporaryDirectory
temporaryDirectory: temporaryDirectory,
returnWorkingDirectories: returnWorkingDirectories
) {
return .command(command)
}
Expand Down Expand Up @@ -1618,6 +1622,12 @@ extension Workspace {
autoResumeAgentSessions: shouldAutoResumeAgent,
promptForApproval: true
)
let bindingReturnWorkingDirectories = [
effectiveResumeBindingForStartup?.cwd,
snapshot.terminal?.workingDirectory,
restorableAgent?.workingDirectory,
snapshot.directory,
]
let remoteStartupCommand = remoteTerminalStartupCommand()
let restoredBindingLaunch: SurfaceResumeStartupLaunch? = if remoteStartupCommand != nil {
effectiveResumeBindingForStartup?
Expand All @@ -1627,16 +1637,23 @@ extension Workspace {
effectiveResumeBindingForStartup.flatMap {
Self.surfaceResumeStartupLaunch(
forApprovedBinding: $0,
allowLauncherScript: true
allowLauncherScript: true,
returnWorkingDirectories: bindingReturnWorkingDirectories
)
}
}
let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding
let savedWorkingDirectory =
effectiveResumeBinding?.cwd
?? snapshot.terminal?.workingDirectory
?? restorableAgent?.workingDirectory
?? snapshot.directory
let savedWorkingDirectory = firstNormalizedTerminalWorkingDirectory([
effectiveResumeBinding?.cwd,
snapshot.terminal?.workingDirectory,
restorableAgent?.workingDirectory,
snapshot.directory,
])
let agentReturnWorkingDirectories = [
snapshot.terminal?.workingDirectory,
restorableAgent?.workingDirectory,
snapshot.directory,
]
let workingDirectory = savedWorkingDirectory
?? currentDirectory
let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil
Expand All @@ -1658,7 +1675,7 @@ extension Workspace {
)
.map(SurfaceResumeStartupLaunch.input)
} else {
restorableAgent?.resumeStartupCommand()
restorableAgent?.resumeStartupCommand(returnWorkingDirectories: agentReturnWorkingDirectories)
.map(SurfaceResumeStartupLaunch.command)
}
} else {
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@
C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */; };
A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; };
C13519000000000000000001 /* TerminalStartupEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000002 /* TerminalStartupEnvironment.swift */; };
D53910010000000000000001 /* TerminalStartupReturnShellScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D53910010000000000000002 /* TerminalStartupReturnShellScriptTests.swift */; };
5154BEAB50364B86A9E36E4B /* TerminalViewportUITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */; };
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
D0B10006A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */; };
Expand Down Expand Up @@ -1211,6 +1212,7 @@
C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSearchOverlayMouseReleaseTests.swift; sourceTree = "<group>"; };
A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; };
C13519000000000000000002 /* TerminalStartupEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupEnvironment.swift; sourceTree = "<group>"; };
D53910010000000000000002 /* TerminalStartupReturnShellScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupReturnShellScriptTests.swift; sourceTree = "<group>"; };
31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TerminalViewportUITestRecorder.swift; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalDebug.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1907,6 +1909,7 @@
3865B0043865B0043865B004 /* SearchIndexTests.swift */,
F5300003A1B2C3D4E5F60718 /* RovoDevTranscriptPreviewTests.swift */,
F5310001A1B2C3D4E5F60718 /* RovoDevHookConfigTests.swift */,
D53910010000000000000002 /* TerminalStartupReturnShellScriptTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
C0DE7B300000000000000002 /* TextBoxMentionCompletionTests.swift */,
Expand Down Expand Up @@ -2993,6 +2996,7 @@
A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */,
A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */,
C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */,
D53910010000000000000001 /* TerminalStartupReturnShellScriptTests.swift in Sources */,
C0DE7B300000000000000001 /* TextBoxMentionCompletionTests.swift in Sources */,
F50030040000000000000001 /* TitlebarInteractiveControlTests.swift in Sources */,
D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */,
Expand Down
8 changes: 4 additions & 4 deletions cmuxTests/AgentSessionAutoResumeSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -666,11 +666,11 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase {
let dir = "/tmp/repo-resume"
let lines = TerminalStartupReturnShellScript.commandThenReturnLines(
command: "{ cd -- '\(dir)' 2>/dev/null || [ ! -d '\(dir)' ]; } && 'claude' '--resume' 'abc'",
workingDirectory: dir
returnWorkingDirectories: [dir]
)
let script = lines.joined(separator: "\n")

let outerCd = "{ cd -- '\(dir)' 2>/dev/null || true; }"
let outerCd = #"{ cd -- '\#(dir)' 2>/dev/null || cd -- "${HOME}" 2>/dev/null || true; }"#
let exec = "exec -l \"$_cmux_resume_shell\""
let outerCdRange = script.range(of: outerCd)
let execRange = script.range(of: exec)
Expand All @@ -683,11 +683,11 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase {
)
}

// Back-compat: with no working directory, no extra outer cd is emitted.
// With no persisted working directory, fall back to HOME instead of inheriting the launcher cwd.
let bare = TerminalStartupReturnShellScript
.commandThenReturnLines(command: "echo hi")
.joined(separator: "\n")
XCTAssertFalse(bare.contains("|| true; }"), bare)
XCTAssertTrue(bare.contains(#"{ cd -- "${HOME}" 2>/dev/null || true; }"#), bare)
XCTAssertTrue(bare.contains(exec), bare)
}

Expand Down
Loading
Loading