diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 56a96726caa5..190ef3d7c10a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -24590,11 +24590,20 @@ struct CMUXCLI { launchCommand: AgentHookLaunchCommandRecord? ) { let resumeEnvironment = agentSurfaceResumeEnvironment(kind: kind, environment: launchCommand?.environment) + // Pin the resume binding to the directory the agent was *launched* in, not the drift-prone + // runtime cwd: cwd-namespaced agents (Claude, Grok, Gemini, …) file their session under the + // launch dir, so resuming from a worktree the agent later `cd`'d into fails with "No + // conversation found". + let resumeWorkingDirectory = AgentResumeWorkingDirectory().resolve( + kind: kind, + runtimeCwd: cwd, + launchWorkingDirectory: launchCommand?.workingDirectory + ) guard let command = agentSurfaceResumeCommand( kind: kind, sessionId: sessionId, launchCommand: launchCommand, - workingDirectory: cwd, + workingDirectory: resumeWorkingDirectory, environment: resumeEnvironment ) else { clearAgentSurfaceResumeBinding( @@ -24614,8 +24623,8 @@ struct CMUXCLI { "command": command, "auto_resume": true ] - if let cwd = normalizedHookValue(cwd) ?? normalizedHookValue(launchCommand?.workingDirectory) { - params["cwd"] = cwd + if let resumeWorkingDirectory { + params["cwd"] = resumeWorkingDirectory } if let resumeEnvironment, !resumeEnvironment.isEmpty { params["environment"] = resumeEnvironment @@ -24651,46 +24660,21 @@ struct CMUXCLI { guard let normalizedSessionId else { return nil } let argv: [String]? - switch launchCommand?.launcher { - case "claudeTeams": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") - var tail = original.tail - let removedToken = tail.first == "claude-teams" - if removedToken { tail.removeFirst() } - argv = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: tail).map { - agentSurfaceResumePrefixedArguments( - executable: original.executable, - token: "claude-teams", - option: "--resume", - sessionId: normalizedSessionId, - preserved: $0 - ) - } - case "omo": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") - var tail = original.tail - let removedToken = tail.first == "omo" - if removedToken { tail.removeFirst() } - argv = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: tail).map { - agentSurfaceResumePrefixedArguments( - executable: original.executable, - token: "omo", - option: "--session", - sessionId: normalizedSessionId, - preserved: $0 - ) - } - case "codexTeams": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") - var tail = original.tail - if tail.first == "codex-teams" { tail.removeFirst() } - argv = AgentLaunchSanitizer.preservedCodexForkArguments(args: tail).map { - [original.executable, "codex-teams", "resume", normalizedSessionId] + $0 - } - case "omx", "omc": - argv = nil - default: - argv = agentSurfaceResumeArguments(kind: kind, sessionId: normalizedSessionId, launchCommand: launchCommand) + switch AgentResumeArgv().launcherResolution( + launcher: launchCommand?.launcher, + sessionId: normalizedSessionId, + executablePath: launchCommand?.executablePath, + arguments: launchCommand?.arguments ?? [] + ) { + case .resolved(let resolved): + argv = resolved + case .passthrough: + argv = AgentResumeArgv().builtInKind( + kind: kind, + sessionId: normalizedSessionId, + executablePath: launchCommand?.executablePath, + arguments: launchCommand?.arguments ?? [] + ) } guard let argv, !argv.isEmpty else { return nil } @@ -24702,102 +24686,6 @@ struct CMUXCLI { ) } - private func agentSurfaceResumeArguments( - kind: String, - sessionId: String, - launchCommand: AgentHookLaunchCommandRecord? - ) -> [String]? { - switch kind { - case "claude": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "claude", option: "--resume", sessionId: sessionId) - case "codex": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - return AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail).map { - [original.executable, "resume", sessionId] + $0 - } - case "pi": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "pi", option: "--session", sessionId: sessionId) - case "grok": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "grok", option: "-r", sessionId: sessionId) - case "amp": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "amp") - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable, "threads", "continue"] + $0 + [sessionId] - } - case "cursor": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "cursor-agent", option: "--resume", sessionId: sessionId) - case "gemini": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "gemini", option: "--resume", sessionId: sessionId) - case "kiro": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "kiro-cli") - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable, "chat", "--resume-id", sessionId] + $0 - } - case "antigravity": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "agy", option: "--conversation", sessionId: sessionId) - case "opencode": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "opencode") - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable, "--session", sessionId] + $0 - } - case "rovodev": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "acli") - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable, "rovodev", "run", "--restore", sessionId] + $0 - } - case "hermes-agent": - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "hermes") - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable] + $0 + ["--resume", sessionId] - } - case "copilot": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "copilot", option: "--resume", sessionId: sessionId) - case "codebuddy": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "codebuddy", option: "--resume", sessionId: sessionId) - case "factory": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "droid", option: "--resume", sessionId: sessionId) - case "qoder": - return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "qodercli", option: "--resume", sessionId: sessionId) - default: - return nil - } - } - - private func agentSurfaceResumeWithOption( - kind: String, - launchCommand: AgentHookLaunchCommandRecord?, - fallbackExecutable: String, - option: String, - sessionId: String - ) -> [String]? { - let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: fallbackExecutable) - return AgentLaunchSanitizer.preservedArguments(kind: kind, args: original.tail).map { - [original.executable, option, sessionId] + $0 - } - } - - private func agentSurfaceResumePrefixedArguments( - executable: String, - token: String, - option: String, - sessionId: String, - preserved: [String] - ) -> [String] { - [executable, token, option, sessionId] + preserved - } - - private func agentSurfaceResumeCommandParts( - launchCommand: AgentHookLaunchCommandRecord?, - fallbackExecutable: String - ) -> (executable: String, tail: [String]) { - let arguments = launchCommand?.arguments ?? [] - let executable = normalizedHookValue(launchCommand?.executablePath) - ?? arguments.first - ?? fallbackExecutable - let tail = arguments.isEmpty ? [] : Array(arguments.dropFirst()) - return (executable: executable, tail: tail) - } - private func agentSurfaceResumeShellCommand( argv: [String], workingDirectory: String?, diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentCwdNamespacing.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentCwdNamespacing.swift new file mode 100644 index 000000000000..3286ef657cc9 --- /dev/null +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentCwdNamespacing.swift @@ -0,0 +1,17 @@ +/// Whether an agent's session store is keyed by the directory the agent was launched in. +/// +/// This decides whether ` --resume ` is sensitive to the directory it runs from, which in +/// turn drives how cmux chooses the working directory when it restores a session. +public enum AgentCwdNamespacing: Sendable, Equatable { + /// The store is keyed by a directory derived from the launch cwd (Claude `projects//`, + /// plus the Grok/Pi/Gemini/Cursor/Qoder cwd-keyed buckets). Resuming from a different directory + /// looks in the wrong namespace and fails with "No conversation found". Kinds whose layout has not + /// been verified are treated as ``byDirectory`` because preferring the launch cwd is never worse + /// for resume lookup. + case byDirectory + + /// Sessions are addressed by id and the cwd is recorded inside the session file (Codex, OpenCode, + /// Amp, Antigravity, Rovo Dev, Hermes). Resume works from any directory, so the runtime cwd can be + /// kept and the agent reopens where it was working. + case cwdInFile +} diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift new file mode 100644 index 000000000000..4c3dcfbe896c --- /dev/null +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Builds the argument vector for an agent's resume/continue command. +/// +/// This is the single source of truth shared by the app-side resume builder +/// (`AgentResumeCommandBuilder` in the app target) and the standalone `cmux-cli` surface-restore +/// publisher (`agentSurfaceResumeCommand`), so both emit identical resume commands. It is pure value +/// logic over primitives (no `AppKit`, `Process`, or socket), so it is testable in isolation. +/// +/// The type is a stateless value; construct one at the call site (`AgentResumeArgv()`) rather than +/// reaching through a static namespace, per the package design discipline. +/// +/// Resolution order mirrors the historical app builder: a cmux wrapper launcher +/// (``launcherResolution(launcher:sessionId:executablePath:arguments:)``) is checked first, then the +/// per-kind verb (``builtInKind(kind:sessionId:executablePath:arguments:)``). Callers that also +/// support custom Vault agents slot that resolution between the two. +public struct AgentResumeArgv: Sendable, Equatable { + /// Creates a resume-argv builder. The type holds no state. + public init() {} + + /// The result of resolving a cmux wrapper launcher (the `claude-teams` / `codex-teams` / `omo` + /// style launchers cmux injects), checked before the per-kind verb. + public enum LauncherResolution: Sendable, Equatable { + /// The launcher is a cmux wrapper; the associated value is its resume argv, or `nil` when the + /// wrapper has no resumable form (e.g. one-shot `omx`/`omc`). + case resolved([String]?) + /// The launcher is a plain agent executable; fall through to ``builtInKind(kind:sessionId:executablePath:arguments:)``. + case passthrough + } + + /// Resolves a resume argv from a cmux wrapper launcher, or ``LauncherResolution/passthrough`` when + /// the launcher is a plain agent executable. + /// + /// - Parameters: + /// - launcher: the captured launcher token (e.g. `"claudeTeams"`, `"omo"`), or `nil`. + /// - sessionId: the session/thread id to resume. + /// - executablePath: the captured executable path, if any. + /// - arguments: the captured launch arguments (argv, including the executable as element 0). + public func launcherResolution( + launcher: String?, + sessionId: String, + executablePath: String?, + arguments: [String] + ) -> LauncherResolution { + switch launcher { + case "claudeTeams": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "cmux") + var tail = parts.tail + if tail.first == "claude-teams" { tail.removeFirst() } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: tail) else { + return .resolved(nil) + } + return .resolved([parts.executable, "claude-teams", "--resume", sessionId] + preserved) + case "codexTeams": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "cmux") + var tail = parts.tail + if tail.first == "codex-teams" { tail.removeFirst() } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: tail) else { + return .resolved(nil) + } + return .resolved([parts.executable, "codex-teams", "resume", sessionId] + preserved) + case "omo": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "cmux") + var tail = parts.tail + if tail.first == "omo" { tail.removeFirst() } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: tail) else { + return .resolved(nil) + } + return .resolved([parts.executable, "omo", "--session", sessionId] + preserved) + case "omx", "omc": + return .resolved(nil) + default: + return .passthrough + } + } + + /// Builds the resume argv for a built-in agent kind, or `nil` if the kind is unknown or its launch + /// arguments cannot be preserved. + /// + /// - Parameters: + /// - kind: the agent's raw kind identifier (e.g. `"claude"`, `"codex"`, `"hermes-agent"`). + /// - sessionId: the session/thread id to resume. + /// - executablePath: the captured executable path, if any. + /// - arguments: the captured launch arguments (argv, including the executable as element 0). + public func builtInKind( + kind: String, + sessionId: String, + executablePath: String?, + arguments: [String] + ) -> [String]? { + switch kind { + case "claude": + return withOption("claude", executable: "claude", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "codex": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "codex") + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: parts.tail) else { return nil } + return [parts.executable, "resume", sessionId] + preserved + case "grok": + return withOption("grok", executable: "grok", option: "-r", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "pi": + return withOption("pi", executable: "pi", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "amp": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "amp") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "amp", args: parts.tail) else { return nil } + return [parts.executable, "threads", "continue"] + preserved + [sessionId] + case "cursor": + return withOption("cursor", executable: "cursor-agent", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "gemini": + return withOption("gemini", executable: "gemini", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "kiro": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "kiro-cli") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "kiro", args: parts.tail) else { return nil } + return [parts.executable, "chat", "--resume-id", sessionId] + preserved + case "antigravity": + return withOption("antigravity", executable: "agy", option: "--conversation", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "opencode": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "opencode") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: parts.tail) else { return nil } + return [parts.executable, "--session", sessionId] + preserved + case "rovodev": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "acli") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "rovodev", args: parts.tail) else { return nil } + return [parts.executable, "rovodev", "run", "--restore", sessionId] + preserved + case "hermes-agent": + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "hermes") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "hermes-agent", args: parts.tail) else { return nil } + return [parts.executable] + preserved + ["--resume", sessionId] + case "copilot": + return withOption("copilot", executable: "copilot", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "codebuddy": + return withOption("codebuddy", executable: "codebuddy", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "factory": + return withOption("factory", executable: "droid", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "qoder": + return withOption("qoder", executable: "qodercli", option: "--resume", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + default: + return nil + } + } + + private func withOption( + _ kind: String, + executable fallbackExecutable: String, + option: String, + sessionId: String, + executablePath: String?, + arguments: [String] + ) -> [String]? { + let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: fallbackExecutable) + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: kind, args: parts.tail) else { return nil } + return [parts.executable, option, sessionId] + preserved + } + + private func commandParts( + executablePath: String?, + arguments: [String], + fallbackExecutable: String + ) -> (executable: String, tail: [String]) { + let executable = normalized(executablePath) ?? normalized(arguments.first) ?? fallbackExecutable + let tail = arguments.isEmpty ? [] : Array(arguments.dropFirst()) + return (executable, tail) + } + + private func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift new file mode 100644 index 000000000000..80371f20750e --- /dev/null +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeWorkingDirectory.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Resolves the working directory a restored agent session should run in. +/// +/// This is the single source of truth shared by the app-side resolver +/// (``RestorableAgentSessionIndex`` in the app target) and the CLI surface-resume-binding publisher +/// (`publishAgentSurfaceResumeBinding` in the standalone `cmux-cli` target), so both apply one +/// policy: directory-namespaced agents pin the launch cwd, id-keyed agents keep the runtime cwd. +/// +/// The type is a stateless value; construct one at the call site (`AgentResumeWorkingDirectory()`) +/// rather than reaching through a static namespace, per the package design discipline. +/// +/// ```swift +/// // A Claude session launched in /repo that drifted into /repo/worktrees/x: +/// AgentResumeWorkingDirectory().resolve( +/// kind: "claude", +/// runtimeCwd: "/repo/worktrees/x", +/// launchWorkingDirectory: "/repo" +/// ) // == "/repo" (so `claude --resume` finds the transcript filed under /repo) +/// ``` +public struct AgentResumeWorkingDirectory: Sendable, Equatable { + /// Creates a working-directory resolver. The type holds no state. + public init() {} + + /// Classifies an agent by its raw kind id (e.g. `"claude"`, `"codex"`, `"hermes-agent"`). + /// + /// - Parameter kind: the agent's raw kind identifier (``RestorableAgentKind/rawValue`` in the app). + /// - Returns: ``AgentCwdNamespacing/cwdInFile`` for id-keyed agents that record the cwd in the + /// session file; ``AgentCwdNamespacing/byDirectory`` for everything else (including unknown + /// kinds, which prefer the launch cwd). + public func cwdNamespacing(forKind kind: String) -> AgentCwdNamespacing { + switch kind { + case "codex", "opencode", "amp", "antigravity", "rovodev", "hermes-agent": + return .cwdInFile + default: + return .byDirectory + } + } + + /// The directory a resumed agent session should `cd` into. + /// + /// Directory-namespaced agents prefer the launch working directory: it is captured once at launch, + /// matches the session store's namespace, and does not drift when the agent `cd`s into a + /// subdirectory (e.g. a worktree) mid-session. Id-keyed agents keep the runtime cwd so they reopen + /// where the agent was working. Inputs are trimmed and empty values are treated as absent. + /// + /// - Parameters: + /// - kind: the agent's raw kind identifier. + /// - runtimeCwd: the agent's last-reported runtime cwd (may have drifted). + /// - launchWorkingDirectory: the directory the agent was launched in. + /// - Returns: the directory to `cd` into, or `nil` when neither input is usable. + public func resolve( + kind: String, + runtimeCwd: String?, + launchWorkingDirectory: String? + ) -> String? { + let runtime = normalized(runtimeCwd) + let launch = normalized(launchWorkingDirectory) + switch cwdNamespacing(forKind: kind) { + case .cwdInFile: + return runtime ?? launch + case .byDirectory: + return launch ?? runtime + } + } + + private func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift new file mode 100644 index 000000000000..fc9da8d73056 --- /dev/null +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift @@ -0,0 +1,109 @@ +import CMUXAgentLaunch +import Testing + +@Suite("AgentResumeArgv") +struct AgentResumeArgvTests { + @Test("Built-in --option style kinds", arguments: [ + ("claude", "claude", ["claude", "--resume", "SID"]), + ("grok", "grok", ["grok", "-r", "SID"]), + ("pi", "pi", ["pi", "--session", "SID"]), + ("cursor", "cursor-agent", ["cursor-agent", "--resume", "SID"]), + ("gemini", "gemini", ["gemini", "--resume", "SID"]), + ("antigravity", "agy", ["agy", "--conversation", "SID"]), + ("copilot", "copilot", ["copilot", "--resume", "SID"]), + ("codebuddy", "codebuddy", ["codebuddy", "--resume", "SID"]), + ("factory", "droid", ["droid", "--resume", "SID"]), + ("qoder", "qodercli", ["qodercli", "--resume", "SID"]), + ]) + func builtInWithOptionKinds(kind: String, executable: String, expected: [String]) { + #expect( + AgentResumeArgv().builtInKind( + kind: kind, sessionId: "SID", executablePath: nil, arguments: [executable] + ) == expected + ) + } + + @Test("Built-in special-shaped kinds") + func builtInSpecialShapes() { + #expect( + AgentResumeArgv().builtInKind(kind: "codex", sessionId: "SID", executablePath: nil, arguments: ["codex"]) + == ["codex", "resume", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "amp", sessionId: "SID", executablePath: nil, arguments: ["amp"]) + == ["amp", "threads", "continue", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "kiro", sessionId: "SID", executablePath: nil, arguments: ["kiro-cli"]) + == ["kiro-cli", "chat", "--resume-id", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "rovodev", sessionId: "SID", executablePath: nil, arguments: ["acli"]) + == ["acli", "rovodev", "run", "--restore", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "hermes-agent", sessionId: "SID", executablePath: nil, arguments: ["hermes"]) + == ["hermes", "--resume", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "opencode", sessionId: "SID", executablePath: nil, arguments: ["opencode"]) + == ["opencode", "--session", "SID"] + ) + #expect( + AgentResumeArgv().builtInKind(kind: "not-an-agent", sessionId: "SID", executablePath: nil, arguments: ["x"]) == nil + ) + } + + @Test("Captured executable path overrides the fallback executable") + func executablePathOverridesFallback() { + #expect( + AgentResumeArgv().builtInKind( + kind: "claude", + sessionId: "SID", + executablePath: "/opt/bin/claude", + arguments: ["/opt/bin/claude"] + ) == ["/opt/bin/claude", "--resume", "SID"] + ) + } + + @Test("cmux wrapper launchers resolve before per-kind verbs") + func launcherWrappers() { + #expect( + AgentResumeArgv().launcherResolution( + launcher: "claudeTeams", sessionId: "SID", executablePath: nil, arguments: ["cmux", "claude-teams"] + ) == .resolved(["cmux", "claude-teams", "--resume", "SID"]) + ) + #expect( + AgentResumeArgv().launcherResolution( + launcher: "codexTeams", sessionId: "SID", executablePath: nil, arguments: ["cmux", "codex-teams"] + ) == .resolved(["cmux", "codex-teams", "resume", "SID"]) + ) + #expect( + AgentResumeArgv().launcherResolution( + launcher: "omo", sessionId: "SID", executablePath: nil, arguments: ["cmux", "omo"] + ) == .resolved(["cmux", "omo", "--session", "SID"]) + ) + // One-shot wrappers have no resumable form (omx and omc share an arm; exercise each). + #expect( + AgentResumeArgv().launcherResolution( + launcher: "omx", sessionId: "SID", executablePath: nil, arguments: ["cmux", "omx"] + ) == .resolved(nil) + ) + #expect( + AgentResumeArgv().launcherResolution( + launcher: "omc", sessionId: "SID", executablePath: nil, arguments: ["cmux", "omc"] + ) == .resolved(nil) + ) + // A plain agent launcher falls through to the per-kind builder. + #expect( + AgentResumeArgv().launcherResolution( + launcher: "claude", sessionId: "SID", executablePath: nil, arguments: ["claude"] + ) == .passthrough + ) + #expect( + AgentResumeArgv().launcherResolution( + launcher: nil, sessionId: "SID", executablePath: nil, arguments: [] + ) == .passthrough + ) + } +} diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift new file mode 100644 index 000000000000..1f133a08b06d --- /dev/null +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeWorkingDirectoryTests.swift @@ -0,0 +1,61 @@ +import CMUXAgentLaunch +import Testing + +@Suite("AgentResumeWorkingDirectory") +struct AgentResumeWorkingDirectoryTests { + @Test("Directory-namespaced agents pin the launch cwd over a drifted runtime cwd") + func directoryNamespacedPrefersLaunch() { + #expect( + AgentResumeWorkingDirectory().resolve( + kind: "claude", + runtimeCwd: "/Users/x/repo/worktrees/feature", + launchWorkingDirectory: "/Users/x/repo" + ) == "/Users/x/repo" + ) + for kind in ["gemini", "cursor", "grok", "pi", "qoder"] { + #expect(AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind) == .byDirectory) + #expect( + AgentResumeWorkingDirectory().resolve( + kind: kind, + runtimeCwd: "/Users/x/repo/sub", + launchWorkingDirectory: "/Users/x/repo" + ) == "/Users/x/repo" + ) + } + } + + @Test("Id-keyed cwd-in-file agents keep the runtime cwd") + func cwdInFileKeepsRuntime() { + for kind in ["codex", "opencode", "amp", "antigravity", "rovodev", "hermes-agent"] { + #expect(AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind) == .cwdInFile) + #expect( + AgentResumeWorkingDirectory().resolve( + kind: kind, + runtimeCwd: "/Users/x/repo/worktrees/feature", + launchWorkingDirectory: "/Users/x/repo" + ) == "/Users/x/repo/worktrees/feature" + ) + } + } + + @Test("Falls back across inputs and treats empty as absent") + func fallbacksAndEmpty() { + #expect( + AgentResumeWorkingDirectory().resolve( + kind: "claude", runtimeCwd: "/Users/x/repo", launchWorkingDirectory: nil + ) == "/Users/x/repo" + ) + #expect( + AgentResumeWorkingDirectory().resolve( + kind: "claude", runtimeCwd: "/Users/x/repo", launchWorkingDirectory: " " + ) == "/Users/x/repo" + ) + #expect( + AgentResumeWorkingDirectory().resolve( + kind: "claude", runtimeCwd: nil, launchWorkingDirectory: "" + ) == nil + ) + // Unknown kinds prefer the launch cwd (never worse for resume lookup). + #expect(AgentResumeWorkingDirectory().cwdNamespacing(forKind: "some-future-agent") == .byDirectory) + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index bd71d5374ce5..0a49026133b2 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -422,43 +422,15 @@ enum AgentResumeCommandBuilder { workingDirectory: String?, customRegistration: CmuxVaultAgentRegistration? ) -> [String]? { - switch launchCommand?.launcher { - case "claudeTeams": - let original = commandParts( - launchCommand: launchCommand, - fallbackExecutable: "cmux" - ) - var args = original.tail - if args.first == "claude-teams" { - args.removeFirst() - } - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: args) else { return nil } - return [original.executable, "claude-teams", "--resume", sessionId] + preserved - case "codexTeams": - let original = commandParts( - launchCommand: launchCommand, - fallbackExecutable: "cmux" - ) - var args = original.tail - if args.first == "codex-teams" { - args.removeFirst() - } - guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: args) else { return nil } - return [original.executable, "codex-teams", "resume", sessionId] + preserved - case "omo": - let original = commandParts( - launchCommand: launchCommand, - fallbackExecutable: "cmux" - ) - var args = original.tail - if args.first == "omo" { - args.removeFirst() - } - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: args) else { return nil } - return [original.executable, "omo", "--session", sessionId] + preserved - case "omx", "omc": - return nil - default: + switch AgentResumeArgv().launcherResolution( + launcher: launchCommand?.launcher, + sessionId: sessionId, + executablePath: launchCommand?.executablePath, + arguments: launchCommand?.arguments ?? [] + ) { + case .resolved(let argv): + return argv + case .passthrough: break } @@ -482,114 +454,12 @@ enum AgentResumeCommandBuilder { return arguments.isEmpty ? nil : arguments } - switch kind { - case .claude: - return resumeWithOption( - kind: "claude", - launchCommand: launchCommand, - fallbackExecutable: "claude", - option: "--resume", - sessionId: sessionId - ) - case .codex: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail) else { return nil } - return [original.executable, "resume", sessionId] + preserved - case .grok: - return resumeWithOption( - kind: "grok", - launchCommand: launchCommand, - fallbackExecutable: "grok", - option: "-r", - sessionId: sessionId - ) - case .pi: - return resumeWithOption( - kind: "pi", - launchCommand: launchCommand, - fallbackExecutable: "pi", - option: "--session", - sessionId: sessionId - ) - case .amp: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "amp") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "amp", args: original.tail) else { return nil } - return [original.executable, "threads", "continue"] + preserved + [sessionId] - case .cursor: - return resumeWithOption( - kind: "cursor", - launchCommand: launchCommand, - fallbackExecutable: "cursor-agent", - option: "--resume", - sessionId: sessionId - ) - case .gemini: - return resumeWithOption( - kind: "gemini", - launchCommand: launchCommand, - fallbackExecutable: "gemini", - option: "--resume", - sessionId: sessionId - ) - case .kiro: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "kiro-cli") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "kiro", args: original.tail) else { return nil } - return [original.executable, "chat", "--resume-id", sessionId] + preserved - case .antigravity: - return resumeWithOption( - kind: "antigravity", - launchCommand: launchCommand, - fallbackExecutable: "agy", - option: "--conversation", - sessionId: sessionId - ) - case .opencode: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "opencode") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: original.tail) else { return nil } - return [original.executable, "--session", sessionId] + preserved - case .rovodev: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "acli") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "rovodev", args: original.tail) else { return nil } - return [original.executable, "rovodev", "run", "--restore", sessionId] + preserved - case .hermesAgent: - let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "hermes") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "hermes-agent", args: original.tail) else { return nil } - return [original.executable] + preserved + ["--resume", sessionId] - case .copilot: - return resumeWithOption( - kind: "copilot", - launchCommand: launchCommand, - fallbackExecutable: "copilot", - option: "--resume", - sessionId: sessionId - ) - case .codebuddy: - return resumeWithOption( - kind: "codebuddy", - launchCommand: launchCommand, - fallbackExecutable: "codebuddy", - option: "--resume", - sessionId: sessionId - ) - case .factory: - return resumeWithOption( - kind: "factory", - launchCommand: launchCommand, - fallbackExecutable: "droid", - option: "--resume", - sessionId: sessionId - ) - case .qoder: - return resumeWithOption( - kind: "qoder", - launchCommand: launchCommand, - fallbackExecutable: "qodercli", - option: "--resume", - sessionId: sessionId - ) - case .custom: - return nil - } + return AgentResumeArgv().builtInKind( + kind: kind.rawValue, + sessionId: sessionId, + executablePath: launchCommand?.executablePath, + arguments: launchCommand?.arguments ?? [] + ) } private static func forkArguments( @@ -851,7 +721,12 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { sessionId: sessionId, fileManager: fileManager, temporaryDirectory: temporaryDirectory, - returnToLoginShell: true + 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. + workingDirectory: registration?.cwd == .ignore + ? nil + : (workingDirectory ?? launchCommand?.workingDirectory) ) else { return nil } @@ -922,7 +797,8 @@ private enum AgentResumeScriptStore { sessionId: String, fileManager: FileManager, temporaryDirectory: URL, - returnToLoginShell: Bool = false + returnToLoginShell: Bool = false, + workingDirectory: String? = nil ) -> URL? { let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true) do { @@ -944,7 +820,10 @@ private enum AgentResumeScriptStore { "rm -f -- \"$0\" 2>/dev/null || true" ] if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(command: command)) + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( + command: command, + workingDirectory: workingDirectory + )) } else { lines.append(command) } @@ -1144,6 +1023,7 @@ struct RestorableAgentSessionIndex: Sendable { workingDirectory: restorableWorkingDirectory( for: effectiveRecord, kind: kind, + registration: registration, fileManager: fileManager, lookup: claudeTranscriptLookup ), @@ -1398,27 +1278,70 @@ struct RestorableAgentSessionIndex: Sendable { /// The directory cmux must `cd` into to resume or fork this session. /// - /// Claude stores a transcript under the project directory derived from the cwd the session - /// was *created* in, and `claude --resume` / `--fork-session` only locate it from that same - /// directory. The hook-reported `cwd` drifts when the agent `cd`s elsewhere mid-session - /// (e.g. starting in a repo root, then moving into a worktree for the rest of the session), - /// so trusting it makes fork/resume fail with "No conversation found". For Claude, prefer the - /// candidate directory whose project folder actually holds the transcript: the launch cwd or - /// the recorded cwd, matched first against the transcript's known storage path, then against - /// the config directory on disk. Falls back to the recorded cwd when neither can be verified. + /// Many agents store their session under a directory derived from the cwd the session was + /// *launched* in (Claude `projects//`, plus the Grok/Pi/Gemini/Cursor/Qoder + /// cwd-keyed buckets), and `--resume` / `--fork` only locate it from that same directory. The + /// hook-reported `cwd` drifts when the agent `cd`s elsewhere mid-session (e.g. starting in a + /// repo root, then moving into a worktree), so trusting it makes resume fail with "No + /// conversation found". For directory-namespaced kinds, prefer the stable launch cwd (it matches + /// the namespace and never drifts); for Claude, first verify which candidate actually holds the + /// transcript. For kinds that key sessions by id and record the cwd inside the session file + /// (Codex, OpenCode, Amp, …), keep the recorded cwd so the resumed agent reopens where it was. private static func restorableWorkingDirectory( for record: RestorableAgentHookSessionRecord, kind: RestorableAgentKind, + registration: CmuxVaultAgentRegistration?, fileManager: FileManager, lookup: ClaudeTranscriptLookupCache ) -> String? { let recordedCwd = normalizedWorkingDirectory(record.cwd) - guard kind == .claude else { return recordedCwd } - let launchCwd = normalizedWorkingDirectory(record.launchCommand?.workingDirectory) + + // Custom Vault agents resume via their own template (which can expand {{cwd}}) and default to + // a `.preserve` cwd policy, so keep the runtime cwd the agent was working in rather than the + // launch dir. `.ignore` agents resume from the current directory, so the snapshot must carry + // no saved cwd at all (downstream restore consumers read `workingDirectory` directly, not just + // the command builder). The by-directory namespace below is only for built-in agents. + if let registration { + return registration.cwd == .ignore ? nil : (recordedCwd ?? launchCwd) + } + + switch kind.cwdNamespacing { + case .cwdInFile: + // Resume is addressed by id and the cwd lives inside the record, so the runtime cwd is + // fine — keeping it preserves the directory the agent was working in. + return recordedCwd ?? launchCwd + case .byDirectory: + if kind == .claude, + let verified = claudeVerifiedRestorableWorkingDirectory( + record: record, + recordedCwd: recordedCwd, + launchCwd: launchCwd, + fileManager: fileManager, + lookup: lookup + ) { + return verified + } + // The launch cwd matches the session namespace and never drifts; fall back to the + // recorded cwd only when no launch cwd was captured. + return launchCwd ?? recordedCwd + } + } + + /// For Claude, returns the candidate directory whose project folder actually holds the + /// transcript — matched first against the transcript's known storage path, then against the + /// config directory on disk — or `nil` when neither can be verified (so the caller prefers the + /// launch cwd instead of the drift-prone recorded cwd). + private static func claudeVerifiedRestorableWorkingDirectory( + record: RestorableAgentHookSessionRecord, + recordedCwd: String?, + launchCwd: String?, + fileManager: FileManager, + lookup: ClaudeTranscriptLookupCache + ) -> String? { guard let sessionId = normalizedNonEmptyValue(record.sessionId), claudeSessionIdIsSafeFilename(sessionId) else { - return recordedCwd ?? launchCwd + return nil } let candidates = [launchCwd, recordedCwd].compactMap { $0 } @@ -1436,7 +1359,7 @@ struct RestorableAgentSessionIndex: Sendable { } } - // No transcript path on record: probe the config directory for the candidate that holds it. + // Probe the config directory for the candidate that holds the transcript on disk. let roots = lookup.configRoots(for: record) if !roots.isEmpty { for candidate in candidates { @@ -1451,7 +1374,7 @@ struct RestorableAgentSessionIndex: Sendable { } } } - return recordedCwd ?? launchCwd + return nil } private static func claudeSessionIdIsSafeFilename(_ sessionId: String) -> Bool { diff --git a/Sources/RestorableAgentTypes.swift b/Sources/RestorableAgentTypes.swift index dd3e089d6de7..cb396821c016 100644 --- a/Sources/RestorableAgentTypes.swift +++ b/Sources/RestorableAgentTypes.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation enum RestorableAgentKind: Codable, Hashable, Sendable { @@ -115,6 +116,14 @@ enum RestorableAgentKind: Codable, Hashable, Sendable { } } + /// How an agent's session store is keyed, which decides whether ` --resume ` is + /// sensitive to the directory it is launched from. Derived from the shared + /// ``AgentResumeWorkingDirectory/cwdNamespacing(forKind:)`` so the app and the standalone CLI + /// apply one classification. + var cwdNamespacing: AgentCwdNamespacing { + AgentResumeWorkingDirectory().cwdNamespacing(forKind: rawValue) + } + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 324d618ecb68..deba59d19ebb 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1280,18 +1280,26 @@ nonisolated enum TerminalStartupReturnShellScript { #"fi"#, ] - static func commandThenReturnLines(command: String) -> [String] { + static func commandThenReturnLines(command: String, workingDirectory: String? = nil) -> [String] { let quotedCommand = TerminalStartupShellQuoting.singleQuoted(command) - return [ + var lines = [ shellLine, #"case "${_cmux_resume_shell:t}" in"#, #" zsh|bash) "$_cmux_resume_shell" -lic \#(quotedCommand) ;;"#, #" csh|tcsh) "$_cmux_resume_shell" -c \#(quotedCommand) ;;"#, #" *) "$_cmux_resume_shell" -c \#(quotedCommand) ;;"#, #"esac"#, - ] + zshIntegrationReentryLines + [ - #"exec -l "$_cmux_resume_shell""# - ] + ] + 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; }"#) + } + lines.append(#"exec -l "$_cmux_resume_shell""#) + return lines } } @@ -1322,7 +1330,10 @@ private enum SurfaceResumeBindingScriptStore { "rm -f -- \"$0\" 2>/dev/null || true" ] if returnToLoginShell { - lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(command: inlineInput)) + lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines( + command: inlineInput, + workingDirectory: binding.cwd + )) } else { lines.append(inlineInput) } diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index a6a1c30746d2..168939f0ad93 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -622,6 +622,75 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { XCTAssertEqual(runningSnapshot.panels.first?.terminal?.resumeBinding?.kind, "tmux") } + // After a session is restored on reload, the UI fork action must still find it. The action + // resolves the conversation via Workspace.forkableAgentSnapshot(forPanelId:), which reads the + // snapshot captured at restore (restoredAgentSnapshotsByPanelId). A restored codex/claude/opencode + // session must therefore still expose a valid fork command + launchable fork input. + @MainActor + func testRestoredSessionRemainsForkable() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let sessionId = "codex-fork-after-restore-session" + let sourceIndex = try makeRestorableAgentIndex( + workspaceId: source.id, + panelId: sourcePanelId, + sessionId: sessionId + ) + let snapshot = source.sessionSnapshot(includeScrollback: false, restorableAgentIndex: sourceIndex) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + + let forkable = try XCTUnwrap( + restored.forkableAgentSnapshot(forPanelId: restoredPanelId), + "a restored session must remain forkable via the UI" + ) + XCTAssertEqual(forkable.sessionId, sessionId) + let forkCommand = try XCTUnwrap(forkable.forkCommand, "restored session must expose a fork command") + XCTAssertTrue(forkCommand.contains("'fork'"), "codex fork verb expected; got: \(forkCommand)") + XCTAssertTrue(forkCommand.contains(sessionId), "fork must reference the restored session id; got: \(forkCommand)") + XCTAssertNotNil( + forkable.forkStartupInput( + fileManager: .default, + temporaryDirectory: FileManager.default.temporaryDirectory + ), + "restored session must produce launchable fork startup input" + ) + } + + // After a resumed agent is killed, the surface must return to the session's launch directory, + // not the surface default. The resume command's own `cd` runs inside the `-lic` child shell, so + // the outer login shell needs an explicit `cd` to the working directory before `exec -l`. + func testResumeLauncherReturnsToLaunchCwdAfterAgentExits() { + let dir = "/tmp/repo-resume" + let lines = TerminalStartupReturnShellScript.commandThenReturnLines( + command: "{ cd -- '\(dir)' 2>/dev/null || [ ! -d '\(dir)' ]; } && 'claude' '--resume' 'abc'", + workingDirectory: dir + ) + let script = lines.joined(separator: "\n") + + let outerCd = "{ cd -- '\(dir)' 2>/dev/null || true; }" + let exec = "exec -l \"$_cmux_resume_shell\"" + let outerCdRange = script.range(of: outerCd) + let execRange = script.range(of: exec) + XCTAssertNotNil(outerCdRange, "launcher must cd the outer shell back to the launch dir; script:\n\(script)") + XCTAssertNotNil(execRange, script) + if let outerCdRange, let execRange { + XCTAssertTrue( + outerCdRange.lowerBound < execRange.lowerBound, + "the return-to-launch-dir cd must run before exec -l; script:\n\(script)" + ) + } + + // Back-compat: with no working directory, no extra outer cd is emitted. + let bare = TerminalStartupReturnShellScript + .commandThenReturnLines(command: "echo hi") + .joined(separator: "\n") + XCTAssertFalse(bare.contains("|| true; }"), bare) + XCTAssertTrue(bare.contains(exec), bare) + } + private func withRestoredDefaults( key: String, defaults: UserDefaults = .standard, diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index 994ae56873f9..7ad78fc73f73 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation import XCTest @@ -310,6 +311,130 @@ final class RestorableAgentSessionIndexTests: XCTestCase { XCTAssertEqual(snapshot.workingDirectory, launchCwd.path) } + // The transcript exists but its project directory encodes to neither the launch cwd nor the + // drifted cwd (an out-of-tree transcript_path), and the config dir holds no matching project + // folder, so neither verifier can confirm a candidate. Resolution must still prefer the launch + // cwd (the session namespace) over the drift-prone recorded cwd, instead of falling back to the + // drift. This is the exact shape that made a build *with* the #5154 fix still fail to resume. + func testClaudeResumePrefersLaunchCwdWhenTranscriptUnverifiable() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-fallback-prefers-launch-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + try fm.createDirectory( + at: configDir.appendingPathComponent("projects", isDirectory: true), + withIntermediateDirectories: true + ) + let launchCwd = root.appendingPathComponent("repo-main", isDirectory: true) + let driftedCwd = root.appendingPathComponent("worktree", isDirectory: true) + try fm.createDirectory(at: launchCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: driftedCwd, withIntermediateDirectories: true) + + // A real transcript whose parent directory name encodes to neither candidate. + let sessionId = "cccccccc-cccc-cccc-cccc-cccccccccccc" + let outOfTreeDir = root.appendingPathComponent("elsewhere", isDirectory: true) + try fm.createDirectory(at: outOfTreeDir, withIntermediateDirectories: true) + let transcriptURL = outOfTreeDir.appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + try writeClaudeTranscript(sessionId: sessionId, transcriptURL: transcriptURL, cwd: launchCwd) + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( + root: root, + sessions: [ + sessionId: driftedHookRecord( + sessionId: sessionId, + workspaceId: workspaceId, + panelId: panelId, + recordedCwd: driftedCwd.path, + launchCwd: launchCwd.path, + configDir: configDir.path, + transcriptPath: transcriptURL.path, + updatedAt: 10 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + + XCTAssertEqual(snapshot.workingDirectory, launchCwd.path) + let resumeCommand = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertTrue( + resumeCommand.contains("cd -- '\(launchCwd.path)'"), + "resume must cd into the launch cwd; got: \(resumeCommand)" + ) + XCTAssertFalse( + resumeCommand.contains(driftedCwd.path), + "resume must not cd into the drifted cwd; got: \(resumeCommand)" + ) + } + + // A directory-namespaced non-Claude agent (Gemini files its session under the launch cwd) whose + // hook-reported cwd drifted into a subdirectory must still resume from the launch cwd. Before + // the fix the resolver short-circuited every non-Claude kind straight to the drifted recorded + // cwd via `guard kind == .claude else { return recordedCwd }`. + func testDirectoryNamespacedNonClaudeAgentResolvesDriftToLaunchCwd() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-gemini-drift-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let launchCwd = root.appendingPathComponent("repo-main", isDirectory: true) + let driftedCwd = root.appendingPathComponent("worktree", isDirectory: true) + try fm.createDirectory(at: launchCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: driftedCwd, withIntermediateDirectories: true) + + let sessionId = "dddddddd-dddd-dddd-dddd-dddddddddddd" + let workspaceId = UUID() + let panelId = UUID() + try writeHookStore( + root: root, + storeFilename: "gemini-hook-sessions.json", + sessions: [ + sessionId: driftedAgentHookRecord( + launcher: "gemini", + sessionId: sessionId, + workspaceId: workspaceId, + panelId: panelId, + recordedCwd: driftedCwd.path, + launchCwd: launchCwd.path, + updatedAt: 10 + ), + ] + ) + + let index = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + let snapshot = try XCTUnwrap(index.snapshot(workspaceId: workspaceId, panelId: panelId)) + + XCTAssertEqual(snapshot.kind, .gemini) + XCTAssertEqual(snapshot.workingDirectory, launchCwd.path) + let resumeCommand = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertTrue( + resumeCommand.contains("cd -- '\(launchCwd.path)'"), + "resume must cd into the launch cwd; got: \(resumeCommand)" + ) + XCTAssertFalse( + resumeCommand.contains(driftedCwd.path), + "resume must not cd into the drifted cwd; got: \(resumeCommand)" + ) + } + + // RestorableAgentKind.cwdNamespacing delegates to the shared AgentResumeWorkingDirectory + // classifier (in CMUXAgentLaunch) so the app-side resolver and the CLI surface-restore publisher + // apply one policy. The shared resolver's own behavior is covered in CMUXAgentLaunchTests. + func testRestorableAgentKindCwdNamespacingMatchesSharedClassifier() { + for kind in RestorableAgentKind.allCases { + XCTAssertEqual( + kind.cwdNamespacing, + AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind.rawValue), + "\(kind.rawValue) namespacing must match the shared classifier" + ) + } + } + func testClaudeWorkflowDirectorySessionUsesSiblingJsonlSessionForResume() throws { let fm = FileManager.default let root = fm.temporaryDirectory @@ -376,6 +501,261 @@ final class RestorableAgentSessionIndexTests: XCTestCase { .replacingOccurrences(of: ".", with: "-") } + // A custom Vault agent defaults to cwd: .preserve and can expand {{cwd}} in its resume template, + // so a restored custom session must keep the runtime cwd it drifted into, not the launch dir. + // (The kind-based namespace classifier would otherwise treat an unknown id as by-directory.) + func testCustomVaultAgentPreservesRuntimeCwdOnRestore() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-custom-cwd-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let launchCwd = root.appendingPathComponent("repo.main", isDirectory: true) + let runtimeCwd = root.appendingPathComponent("worktree", isDirectory: true) + try fm.createDirectory(at: launchCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: runtimeCwd, withIntermediateDirectories: true) + + let agentId = "my-agent" + let registry = CmuxVaultAgentRegistry(registrations: [ + CmuxVaultAgentRegistration( + id: agentId, + name: "My Agent", + detect: CmuxVaultAgentDetectRule(processNames: [agentId]), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "{{executable}} --resume {{sessionId}}", + cwd: .preserve + ), + ]) + + let ws = UUID() + let panel = UUID() + let sid = "77777777-7777-7777-7777-777777777777" + try writeHookStore( + root: root, + storeFilename: "\(agentId)-hook-sessions.json", + sessions: [ + sid: driftedAgentHookRecord( + launcher: agentId, sessionId: sid, workspaceId: ws, panelId: panel, + recordedCwd: runtimeCwd.path, launchCwd: launchCwd.path, updatedAt: 10 + ), + ] + ) + + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load( + homeDirectory: root.path, + fileManager: fm, + registry: registry, + detectedSnapshots: [:], + processArgumentsProvider: { _ in nil } + ).snapshot(workspaceId: ws, panelId: panel), + "custom agent snapshot" + ) + XCTAssertEqual( + snapshot.workingDirectory, runtimeCwd.path, + "a custom .preserve agent must keep the runtime cwd it drifted into, not the launch dir" + ) + let resume = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertTrue(resume.contains(runtimeCwd.path), "resume must cd into the runtime cwd; got: \(resume)") + XCTAssertFalse(resume.contains(launchCwd.path), "resume must not fall back to the launch dir; got: \(resume)") + } + + // Forking branches a NEW session off an existing one. The fork command must use the correct + // per-agent fork verb and cd into the session's directory, so the forked session launches in the + // right place and is itself resumable. (Claude fork is covered above; this covers the cwd-in-file + // fork agents codex + opencode.) + func testForkCommandUsesPerAgentVerbAndSessionCwd() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-fork-agents-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let cases: [(launcher: String, store: String, verbNeedles: [String])] = [ + ("codex", "codex-hook-sessions.json", ["'fork'"]), + ("opencode", "opencode-hook-sessions.json", ["'--session'", "'--fork'"]), + ] + for testCase in cases { + let ws = UUID() + let panel = UUID() + let sid = "55555555-5555-5555-5555-555555555555" + try writeHookStore( + root: root, + storeFilename: testCase.store, + sessions: [ + sid: driftedAgentHookRecord( + launcher: testCase.launcher, sessionId: sid, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + ), + ] + ) + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: ws, panelId: panel), + "\(testCase.launcher): snapshot" + ) + let fork = try XCTUnwrap(snapshot.forkCommand, "\(testCase.launcher): forkCommand") + XCTAssertTrue( + fork.contains("cd -- '\(dir.path)'"), + "\(testCase.launcher): fork must cd into the session dir; got: \(fork)" + ) + XCTAssertTrue(fork.contains("'\(sid)'"), "\(testCase.launcher): fork must reference the session id; got: \(fork)") + for needle in testCase.verbNeedles { + XCTAssertTrue(fork.contains(needle), "\(testCase.launcher): fork must use its fork verb \(needle); got: \(fork)") + } + // The forked session must be launchable (and therefore itself resumable). + XCTAssertNotNil( + snapshot.forkStartupInput(fileManager: fm, temporaryDirectory: root), + "\(testCase.launcher): forked session must be launchable" + ) + } + } + + // Agents without a fork verb must not emit a fork command (a malformed one would launch a broken + // session). This pins which agents support fork so the set is explicit. + func testNonForkAgentsProduceNoForkCommand() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-nofork-agents-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + for launcher in ["gemini", "grok", "amp", "cursor"] { + let ws = UUID() + let panel = UUID() + let sid = "66666666-6666-6666-6666-666666666666" + try writeHookStore( + root: root, + storeFilename: "\(launcher)-hook-sessions.json", + sessions: [ + sid: driftedAgentHookRecord( + launcher: launcher, sessionId: sid, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + ), + ] + ) + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: ws, panelId: panel), + "\(launcher): snapshot" + ) + // It still resumes; it just has no fork form. + XCTAssertNotNil(snapshot.resumeCommand, "\(launcher): must still resume") + XCTAssertNil(snapshot.forkCommand, "\(launcher): has no fork support and must not emit a fork command") + } + } + + // Spawn an agent, end it, spawn a new one on the same surface: restore must pick the NEWEST + // session (highest updatedAt), not the stale earlier one. + func testReplacementRestoresNewestSessionForSurface() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-newest-wins-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let ws = UUID() + let panel = UUID() + let oldId = "11111111-1111-1111-1111-111111111111" + let newId = "22222222-2222-2222-2222-222222222222" + try writeHookStore( + root: root, + storeFilename: "gemini-hook-sessions.json", + sessions: [ + oldId: driftedAgentHookRecord( + launcher: "gemini", sessionId: oldId, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + ), + newId: driftedAgentHookRecord( + launcher: "gemini", sessionId: newId, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 20 + ), + ] + ) + + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: ws, panelId: panel) + ) + XCTAssertEqual(snapshot.sessionId, newId, "the surface must resume the newest session, not the replaced one") + } + + // Reopening the app multiple times must restore the same session each time (load is pure over + // the on-disk store). + func testRestoreIsIdempotentAcrossReloads() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-idempotent-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let ws = UUID() + let panel = UUID() + let sid = "33333333-3333-3333-3333-333333333333" + try writeHookStore( + root: root, + storeFilename: "gemini-hook-sessions.json", + sessions: [ + sid: driftedAgentHookRecord( + launcher: "gemini", sessionId: sid, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10 + ), + ] + ) + + var ids: [String?] = [] + var commands: [String?] = [] + for _ in 0..<3 { + let snap = RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: ws, panelId: panel) + ids.append(snap?.sessionId) + commands.append(snap?.resumeCommand) + } + XCTAssertEqual(ids, [sid, sid, sid]) + XCTAssertEqual(Set(commands.compactMap { $0 }).count, 1, "resume command must be stable across reloads") + } + + // A session whose recorded process is no longer alive (the agent was killed) must NOT restore + // from the hook index, even though the record is still on disk. + func testKilledSessionWithDeadProcessDoesNotRestore() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-killed-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + let dir = root.appendingPathComponent("repo", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let ws = UUID() + let panel = UUID() + let sid = "44444444-4444-4444-4444-444444444444" + try writeHookStore( + root: root, + storeFilename: "gemini-hook-sessions.json", + sessions: [ + sid: driftedAgentHookRecord( + launcher: "gemini", sessionId: sid, workspaceId: ws, panelId: panel, + recordedCwd: dir.path, launchCwd: dir.path, updatedAt: 10, pid: 999_999 + ), + ] + ) + + let registry = CmuxVaultAgentRegistry.load(homeDirectory: root.path, fileManager: fm) + let index = RestorableAgentSessionIndex.load( + homeDirectory: root.path, + fileManager: fm, + registry: registry, + detectedSnapshots: [:], + processArgumentsProvider: { _ in nil } + ) + XCTAssertNil( + index.snapshot(workspaceId: ws, panelId: panel), + "a killed session whose recorded process is dead must not restore" + ) + } + private func driftedHookRecord( sessionId: String, workspaceId: UUID, @@ -409,6 +789,37 @@ final class RestorableAgentSessionIndexTests: XCTestCase { return record } + // A drifted hook record for an arbitrary (non-Claude) agent: the recorded runtime cwd differs + // from the frozen launch working directory, mirroring the production drift in CLI/cmux.swift. + private func driftedAgentHookRecord( + launcher: String, + sessionId: String, + workspaceId: UUID, + panelId: UUID, + recordedCwd: String, + launchCwd: String, + updatedAt: TimeInterval, + pid: Int? = nil + ) -> [String: Any] { + [ + "sessionId": sessionId, + "workspaceId": workspaceId.uuidString, + "surfaceId": panelId.uuidString, + "cwd": recordedCwd, + "pid": pid.map { $0 as Any } ?? NSNull(), + "isRestorable": true, + "updatedAt": updatedAt, + "launchCommand": [ + "launcher": launcher, + "executablePath": "/usr/local/bin/\(launcher)", + "arguments": ["/usr/local/bin/\(launcher)"], + "workingDirectory": launchCwd, + "capturedAt": updatedAt, + "source": "test", + ], + ] + } + private func hookRecord( sessionId: String, workspaceId: UUID, @@ -501,6 +912,14 @@ final class RestorableAgentSessionIndexTests: XCTestCase { } private func writeClaudeHookStore(root: URL, sessions: [String: [String: Any]]) throws { + try writeHookStore(root: root, storeFilename: "claude-hook-sessions.json", sessions: sessions) + } + + private func writeHookStore( + root: URL, + storeFilename: String, + sessions: [String: [String: Any]] + ) throws { let stateDir = root.appendingPathComponent(".cmuxterm", isDirectory: true) try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) let data = try JSONSerialization.data( @@ -511,7 +930,7 @@ final class RestorableAgentSessionIndexTests: XCTestCase { options: [.prettyPrinted, .sortedKeys] ) try data.write( - to: stateDir.appendingPathComponent("claude-hook-sessions.json", isDirectory: false), + to: stateDir.appendingPathComponent(storeFilename, isDirectory: false), options: .atomic ) } diff --git a/plans/feat-codex-surface-jumble/DESIGN.md b/plans/feat-codex-surface-jumble/DESIGN.md new file mode 100644 index 000000000000..687ca623c732 --- /dev/null +++ b/plans/feat-codex-surface-jumble/DESIGN.md @@ -0,0 +1,38 @@ +# Codex sessions jumble (restore into the wrong surface) after reload + +Root cause from an adversarially-verified workflow (4 investigators + verify + synthesis, high confidence). Distinct from the cwd-drift work (#5300/#5312): that is the **directory** axis; this is the **surface-mapping** axis. Own PR. + +## Symptom +After reload, a codex session restores into the WRONG surface/pane (single-leak = wrong surface; with a second codex, the two surfaces swap sessions). The cwd is correct in each (codex is `.cwdInFile` and keeps its recorded cwd), so it looks like a coherent session in the wrong pane — "jumbled". + +Only reproduces via the codex-family CLI launchers (`cmux omx` / oh-my-codex, `cmux codex-teams`, the teams launchers). A plain `codex` started directly in a shell does NOT jumble. + +## Root cause (durable bug = wrong surfaceId written at spawn/hook time, NOT a restore mis-map) +`CLI/cmux.swift:17301-17306` (`configureTmuxCompatEnvironment`) overwrites **only** `CMUX_WORKSPACE_ID` / `CMUX_SURFACE_ID` from the operator's globally-focused pane (`focusedContext`, resolved via `system.identify` → `TerminalController.swift:4178-4181` selectedTabId+focusedPanelId), while leaving `CMUX_PANEL_ID` / `CMUX_TAB_ID` at the launch surface. So codex launched in surface B while A is focused gets `CMUX_SURFACE_ID=A` but `CMUX_PANEL_ID=B` (desynced). This helper feeds OMX/OMO/OMC + claude-teams. + +Contrast: the in-app per-surface terminal env (`Sources/TerminalStartupEnvironment.swift:40-44` `applyManagedCmuxContextEnvironment`) sets all four IDs from the surface's OWN id (matched), which is why plain codex is fine. + +The leaked `CMUX_SURFACE_ID=A` then: +- is **preferred over PID truth** by the codex/generic hook: `runGenericAgentHook` reads `directSurfaceArg = env CMUX_SURFACE_ID` (`CLI/cmux.swift:26912-26913`); the processBinding PID corrector is **suppressed** by the guard at `26945-26947` precisely when both env IDs are populated; `resolveTarget` prefers `preferredSurfaceId = env` first (`27134-27136`). +- is **persisted durably** as the SurfaceResumeBinding: `publishAgentSurfaceResumeBinding` → `surface.resume.set` (`24379-24394`) → `TerminalController.swift:8772 setSurfaceResumeBinding(panelId: A)` → serialized into the panel snapshot → re-bound inline at reload (`Workspace.swift:1602`). **This path has NO live-pid gate**, which is why the wrong id survives reload. + +The restore map (`RestorableAgentSession.swift:1009-1010,1033`) faithfully propagates the poisoned id; its one corrector (`liveScopedProcessID` / `matchesCMUXScope`) is defeated because `CmuxTopSnapshotScopeCache.swift:113-114` reads `CMUX_SURFACE_ID` before `CMUX_PANEL_ID`, so the live process corroborates the leak. + +`codex-teams` root does NOT call `configureTmuxCompatEnvironment` (root codex inherits the correct `launcherEnvironment`); its parallel leak rides the watcher CLI args at `CLI/cmux.swift:18276-18279` (`--workspace-id focusedContext.workspaceId --surface-id rootSurfaceId`, `rootSurfaceId = focusedContext.surfaceId` at 18178). + +## Fix plan (two-commit red/green) +1. **PRIMARY** `CLI/cmux.swift:17301-17306`: prefer the launching process's OWN `CMUX_SURFACE_ID`/`CMUX_WORKSPACE_ID` (set correctly at terminal creation by `applyManagedCmuxContextEnvironment`); fall back to `focusedContext` only when the process has no own id. Never overwrite to a value that disagrees with the inherited `CMUX_PANEL_ID`/`CMUX_TAB_ID`. Keep `focusedContext` only for the cosmetic `TMUX`/`TMUX_PANE` shim (17274-17284). One helper feeds OMX/OMO/OMC + claude-teams. +2. **SYMMETRIC** `CLI/cmux.swift:18276-18279` (runCodexTeams watcher args): pass the launcher's own surface/workspace, not `focusedContext`. +3. **DEFENSIVE** `CLI/cmux.swift:26945-26947`: drop the `directSurfaceArg != nil` term from the guard so the PID/TTY corrector (`resolveAgentProcessTerminalBinding`, 21717-21756) still runs and can override a leaked env even when both env IDs are populated. (Mirror claude's `resolvePreferredSurfaceIdForClaudeHook(preferred: mappedSession?.surfaceId)` at 20854-20859 for id-keyed agents on prompt-submit/teardown.) Note: does not fix session-start write (mapped is nil there), so #1 stays primary. +4. **OPTIONAL** `Sources/CmuxTopSnapshotScopeCache.swift:113-114`: key the scope check on `CMUX_PANEL_ID`/launch-cwd (which the leak never corrupts) so restore self-heals against future single-key leaks. Low priority. + +## Test plan +- **True-source** (seam = launcher env builder): drive `configureTmuxCompatEnvironment`/`configureOMXEnvironment` with own `CMUX_SURFACE_ID`/`CMUX_PANEL_ID`=B and a stubbed focused-context resolver returning A; assert child env `CMUX_SURFACE_ID==B` AND `CMUX_SURFACE_ID==CMUX_PANEL_ID` (matched-pair invariant). RED: A while PANEL=B. Needs a seam to inject the focused-context resolver. +- **Symmetric codex-teams**: focused=A, launch=B; assert watcher argv `--surface-id`/`--workspace-id` (18276-18279) == B. +- **Hook guard** (seam = subprocess `cmux hooks codex session-start` via `CLINotifyProcessIntegrationRegressionTests`): leaked env A, PID in surface B; capture `surface.resume.set` → RED surface_id=A, GREEN=B after dropping the 26945 guard term. +- **Survives-reload** (seam = SurfaceResumeBinding persistence): `v2SurfaceResumeSet(surface_id=A)` → serialize → reload → assert `createPanel` re-binds into A before, B after. No pid gate → deterministic. +- **DON'T** rely on a synthetic `RestorableAgentSessionIndex.load`-only test: it keys faithfully by `record.surfaceId`, so a hand-written wrong id resolves identically red and green and proves nothing. + +## Related issues +- https://github.com/manaflow-ai/cmux/issues/4920 (spawn env leaks focused workspace/surface IDs) — the source. +- https://github.com/manaflow-ai/cmux/issues/695 (codex-hook routes to wrong workspace/session) — the symptom; codex prefers env over the session-keyed mapping unlike claude-hook.