Skip to content
Merged
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
166 changes: 27 additions & 139 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
}
if let resumeEnvironment, !resumeEnvironment.isEmpty {
params["environment"] = resumeEnvironment
Expand Down Expand Up @@ -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 }
Expand All @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// Whether an agent's session store is keyed by the directory the agent was launched in.
///
/// This decides whether `<agent> --resume <id>` 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/<encode(cwd)>/`,
/// 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
}
Loading
Loading