From 0924ff894be0837a289491338f57e0d3b9320551 Mon Sep 17 00:00:00 2001 From: Allen Date: Sun, 31 May 2026 10:20:35 +1200 Subject: [PATCH 1/3] test: add failing regression for agent resume launch directory Resuming an agent (e.g. Claude Code) after the app reopens generated a `cd && --resume ` command that changed into the agent's *drifted* current working directory instead of the directory it was launched in. Claude keys its session transcript to the launch directory, so resuming elsewhere fails with "No conversation found with session ID". This commit adds the regression test only; it fails against the current behavior. The fix follows in the next commit. --- ...storableAgentHookProviderResumeTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 1bf75daedca7..3f18f1893427 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -640,4 +640,26 @@ extension SocketListenerAcceptPolicyTests { ] ) } + + func testClaudeResumeCommandChangesToLaunchDirectoryNotDriftedCwd() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "59729506-e83c-4bfe-a730-1d50d10cc396", + workingDirectory: "/tmp/claude repo/nested", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "/Users/example/.local/bin/claude", + arguments: ["/Users/example/.local/bin/claude"], + workingDirectory: "/tmp/claude repo", + environment: nil, + capturedAt: 123, + source: "process" + ) + ) + + XCTAssertEqual( + snapshot.resumeCommand, + "{ cd -- '/tmp/claude repo' 2>/dev/null || [ ! -d '/tmp/claude repo' ]; } && '/Users/example/.local/bin/claude' '--resume' '59729506-e83c-4bfe-a730-1d50d10cc396'" + ) + } } From 11478591de8b3b3e00df4f85f2c6c4ce85f03842 Mon Sep 17 00:00:00 2001 From: Allen Date: Sun, 31 May 2026 10:20:52 +1200 Subject: [PATCH 2/3] fix(agent-resume): resume from launch directory, not drifted cwd The resume/fork command builders resolved the `cd` target as `workingDirectory ?? launchCommand?.workingDirectory`, preferring the agent's recorded (drifted) cwd over the directory it was launched in. Claude Code keys its session transcript to the launch directory, so resuming from a drifted cwd fails with "No conversation found with session ID". Flip the precedence to `launchCommand?.workingDirectory ?? workingDirectory` in both the app-side builder (AgentResumeCommandBuilder.shellCommand and the custom-resume {{cwd}} template) and the CLI auto-resume binding (agentSurfaceResumeCommand + the binding cwd metadata). This matches the precedence already used by AgentForkSupport.openCodeProbeWorkingDirectory. Behavior is unchanged when the launch dir and recorded cwd agree (the common case) or when launchCommand is absent (falls back to the recorded cwd); it only differs under cwd drift, where launch dir is correct for every agent kind. Makes the regression test from the previous commit pass. --- CLI/cmux.swift | 4 ++-- Sources/RestorableAgentSession.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b7d6f4e8cc31..0dc9f7ab731c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -24320,7 +24320,7 @@ struct CMUXCLI { "command": command, "auto_resume": true ] - if let cwd = normalizedHookValue(cwd) ?? normalizedHookValue(launchCommand?.workingDirectory) { + if let cwd = normalizedHookValue(launchCommand?.workingDirectory) ?? normalizedHookValue(cwd) { params["cwd"] = cwd } if let resumeEnvironment, !resumeEnvironment.isEmpty { @@ -24402,7 +24402,7 @@ struct CMUXCLI { guard let argv, !argv.isEmpty else { return nil } return agentSurfaceResumeShellCommand( argv: argv, - workingDirectory: workingDirectory ?? launchCommand?.workingDirectory, + workingDirectory: launchCommand?.workingDirectory ?? workingDirectory, kind: kind, environment: environment ) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 8152d8c72432..b77d0a116a73 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -362,7 +362,7 @@ enum AgentResumeCommandBuilder { let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore ? nil - : normalized(workingDirectory ?? launchCommand?.workingDirectory) + : normalized(launchCommand?.workingDirectory ?? workingDirectory) let sanitizedCommandParts = customRegistration == nil ? AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: commandParts, @@ -670,7 +670,7 @@ enum AgentResumeCommandBuilder { "sessionId": sessionId, "sessionPath": sessionId, "executable": original.executable, - "cwd": normalized(workingDirectory ?? launchCommand?.workingDirectory) ?? "", + "cwd": normalized(launchCommand?.workingDirectory ?? workingDirectory) ?? "", "sessionDir": sessionDirectory ?? "", ] var resolved: [String] = [] From 511cdaec320f411fb6cbcd91f8220b5ef51c4ca6 Mon Sep 17 00:00:00 2001 From: Allen Date: Mon, 1 Jun 2026 12:21:29 +1200 Subject: [PATCH 3/3] fix(agent-resume): respect cwd: .ignore in custom resume templates customResumeArguments backfilled {{cwd}} from launchCommand?.workingDirectory even when the registration set cwd: .ignore, bypassing the explicit directive that shellCommand already honored. Compute the cwd up front and suppress it for .ignore registrations, matching shellCommand's guard. Move the new resume regression test to a Swift Testing suite per AGENTS.md and add coverage for the customResumeArguments {{cwd}} path (launch-dir precedence and .ignore suppression). --- Sources/RestorableAgentSession.swift | 5 +- ...storableAgentHookProviderResumeTests.swift | 74 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b77d0a116a73..d2221887ea47 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -666,11 +666,14 @@ enum AgentResumeCommandBuilder { let sessionDirectory = normalized(registration.sessionDirectory).map { ($0 as NSString).expandingTildeInPath } + let resolvedCwd = registration.cwd == .ignore + ? nil + : normalized(launchCommand?.workingDirectory ?? workingDirectory) let replacements: [String: String] = [ "sessionId": sessionId, "sessionPath": sessionId, "executable": original.executable, - "cwd": normalized(launchCommand?.workingDirectory ?? workingDirectory) ?? "", + "cwd": resolvedCwd ?? "", "sessionDir": sessionDirectory ?? "", ] var resolved: [String] = [] diff --git a/cmuxTests/RestorableAgentHookProviderResumeTests.swift b/cmuxTests/RestorableAgentHookProviderResumeTests.swift index 3f18f1893427..426ebe78f7f5 100644 --- a/cmuxTests/RestorableAgentHookProviderResumeTests.swift +++ b/cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -1,4 +1,5 @@ import CMUXAgentLaunch +import Testing import XCTest #if canImport(cmux_DEV) @@ -640,8 +641,12 @@ extension SocketListenerAcceptPolicyTests { ] ) } +} - func testClaudeResumeCommandChangesToLaunchDirectoryNotDriftedCwd() { +@Suite +struct RestorableAgentResumeWorkingDirectoryTests { + @Test + func claudeResumeCommandChangesToLaunchDirectoryNotDriftedCwd() { let snapshot = SessionRestorableAgentSnapshot( kind: .claude, sessionId: "59729506-e83c-4bfe-a730-1d50d10cc396", @@ -657,9 +662,70 @@ extension SocketListenerAcceptPolicyTests { ) ) - XCTAssertEqual( - snapshot.resumeCommand, - "{ cd -- '/tmp/claude repo' 2>/dev/null || [ ! -d '/tmp/claude repo' ]; } && '/Users/example/.local/bin/claude' '--resume' '59729506-e83c-4bfe-a730-1d50d10cc396'" + #expect( + snapshot.resumeCommand + == "{ cd -- '/tmp/claude repo' 2>/dev/null || [ ! -d '/tmp/claude repo' ]; } && '/Users/example/.local/bin/claude' '--resume' '59729506-e83c-4bfe-a730-1d50d10cc396'" + ) + } + + @Test + func customVaultResumeTemplateUsesLaunchDirectoryNotDriftedCwd() throws { + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom("vault-tool"), + sessionId: "session-123", + workingDirectory: "/tmp/vault-drifted", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "vault-tool", + executablePath: "/usr/local/bin/vault-tool", + arguments: ["/usr/local/bin/vault-tool"], + workingDirectory: "/tmp/vault-launch", + environment: nil, + capturedAt: 123, + source: "process" + ), + registration: CmuxVaultAgentRegistration( + id: "vault-tool", + name: "Vault Tool", + detect: CmuxVaultAgentDetectRule(processName: "vault-tool"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "{{executable}} --resume {{sessionId}} --dir {{cwd}}", + cwd: .preserve + ) ) + + let command = try #require(snapshot.resumeCommand) + #expect(command.contains("/tmp/vault-launch")) + #expect(!command.contains("/tmp/vault-drifted")) + } + + @Test + func customVaultResumeTemplateSuppressesCwdWhenIgnored() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom("vault-tool"), + sessionId: "session-123", + workingDirectory: "/tmp/vault-drifted", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "vault-tool", + executablePath: "/usr/local/bin/vault-tool", + arguments: ["/usr/local/bin/vault-tool"], + workingDirectory: "/tmp/vault-launch", + environment: nil, + capturedAt: 123, + source: "process" + ), + registration: CmuxVaultAgentRegistration( + id: "vault-tool", + name: "Vault Tool", + detect: CmuxVaultAgentDetectRule(processName: "vault-tool"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "{{executable}} --resume {{sessionId}} --dir {{cwd}}", + cwd: .ignore + ) + ) + + // cwd: .ignore must keep the launch dir out of the resolved command entirely. + let command = snapshot.resumeCommand ?? "" + #expect(!command.contains("/tmp/vault-launch")) + #expect(!command.contains("/tmp/vault-drifted")) } }