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
161 changes: 161 additions & 0 deletions Sources/ClaudeSessionTranscriptSeeder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import CMUXAgentLaunch
import Darwin
import Foundation

/// Seeds a Claude Code session transcript into the project dir of a new working
/// directory before a `claude --resume <id>` launch. Claude scopes resume
/// lookups to `<config>/projects/<encoded-cwd>/`, so forking or restoring a
/// conversation into a different directory fails with "No conversation found"
/// unless the transcript is copied there first.
/// https://github.com/manaflow-ai/cmux/issues/5941
enum ClaudeSessionTranscriptSeeder {
/// Claude Code project dir name for a working directory: the absolute path
/// with every non-alphanumeric character replaced by `-`. Claude resolves
/// symlinks before encoding (Node `process.cwd()`), so `/tmp/x` is stored
/// as `-private-tmp-x`.
static func encodedProjectDirName(forWorkingDirectory workingDirectory: String) -> String {
let resolved = symlinkResolvedAbsolutePath(workingDirectory)
return String(resolved.map { character in
character.isASCII && (character.isLetter || character.isNumber) ? character : "-"
})
Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the existing Claude project-dir encoder

For target cwd paths containing other non-alphanumeric characters such as underscores or spaces, this new encoder seeds the transcript under a different directory than the rest of cmux still treats as Claude's project directory (RestorableAgentSessionIndex.encodeClaudeProjectDir and its tests only replace / and .). In that case the startup command still cds into the target cwd, but the copied transcript is placed in the mismatched projects/<new-encoding>/ bucket, so claude --resume can continue to report no conversation found. Please share one encoder/contract (or update all call sites/tests together) so the seed location matches the resume lookup location.

Useful? React with 👍 / 👎.

}

/// Config dirs the session transcript may live in, most specific first.
/// The FIRST candidate is authoritative: the launch snapshot's captured
/// CLAUDE_CONFIG_DIR (re-applied via `env` on resume) when present, else
/// this process's CLAUDE_CONFIG_DIR, else `~/.claude`. Each value goes
/// through the same legacy-path preference the launch environment policy
/// applies (`ClaudeConfigDirectoryPath.preferredPath`), so the seeded root
/// is the root the resumed claude actually reads.
static func defaultConfigDirCandidates(
launchEnvironment: [String: String]?,
processEnvironment: [String: String] = ProcessInfo.processInfo.environment,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
fileManager: FileManager = .default
) -> [URL] {
var seenPaths = Set<String>()
var candidates: [URL] = []
func add(_ path: String?) {
guard let path, !path.trimmingCharacters(in: .whitespaces).isEmpty else { return }
let preferred = ClaudeConfigDirectoryPath.preferredPath(
path,
fileManager: fileManager,
homeDirectory: homeDirectory.path
)
let url = URL(fileURLWithPath: preferred)
if seenPaths.insert(url.standardizedFileURL.path).inserted {
candidates.append(url)
}
}
add(launchEnvironment?["CLAUDE_CONFIG_DIR"])
add(processEnvironment["CLAUDE_CONFIG_DIR"])
add(homeDirectory.appendingPathComponent(".claude").path)
return candidates
Comment thread
cursor[bot] marked this conversation as resolved.
}

/// Copies `projects/<source>/<id>.jsonl` (and the optional `<id>/` sidecar
/// dir) into `projects/<encoded-target-cwd>/` of the FIRST candidate (the
/// config root the resumed claude reads); later candidates are searched as
/// source fallbacks only, never written to. No-op when the authoritative
/// target project dir already has the transcript. A copy rather than a
/// hardlink so concurrent resumes of the same id from two cwds cannot
/// interleave appends into one inode. Returns true when the transcript is
/// present in the authoritative target project dir after the call.
/// Best-effort: copy failures must never block the launch.
@discardableResult
static func seedIfNeeded(
sessionId: String,
targetWorkingDirectory: String,
configDirCandidates: [URL],
fileManager: FileManager = .default
) -> Bool {
guard isPlausibleSessionId(sessionId),
let targetConfigDir = configDirCandidates.first else {
return false
}
let targetName = encodedProjectDirName(forWorkingDirectory: targetWorkingDirectory)
guard !targetName.isEmpty else { return false }
let transcriptName = "\(sessionId).jsonl"
let targetDir = targetConfigDir
.appendingPathComponent("projects")
.appendingPathComponent(targetName)
let targetTranscript = targetDir.appendingPathComponent(transcriptName)
if fileManager.fileExists(atPath: targetTranscript.path) {
return true
}

for configDir in configDirCandidates {
let projects = configDir.appendingPathComponent("projects")
guard let projectDirs = try? fileManager.contentsOfDirectory(
at: projects, includingPropertiesForKeys: nil) else {
continue
}
for projectDir in projectDirs {
let sourceTranscript = projectDir.appendingPathComponent(transcriptName)
Comment on lines +87 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Choose the latest duplicate transcript when seeding

After this copy-based seeding runs once, the same Claude session id can exist in multiple projects/<cwd>/ directories; if the user continues the resumed conversation in the new cwd and later forks/restores it into a third cwd, this loop copies the first matching <session>.jsonl returned by directory enumeration rather than the most recently updated transcript. In that scenario the new pane can resume from a stale copy and miss the latest conversation turns, so the seeder should select the newest/source-authoritative transcript instead of returning on the first match.

Useful? React with 👍 / 👎.

guard sourceTranscript.standardizedFileURL.path != targetTranscript.standardizedFileURL.path,
isFile(at: sourceTranscript, fileManager: fileManager) else {
continue
}
do {
try fileManager.createDirectory(at: targetDir, withIntermediateDirectories: true)
try fileManager.copyItem(at: sourceTranscript, to: targetTranscript)
} catch {
continue
}
let sidecar = projectDir.appendingPathComponent(sessionId)
let sidecarTarget = targetDir.appendingPathComponent(sessionId)
if isDirectory(at: sidecar, fileManager: fileManager),
!fileManager.fileExists(atPath: sidecarTarget.path) {
try? fileManager.copyItem(at: sidecar, to: sidecarTarget)
}
return true
}
}
Comment on lines +87 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cross-configDir copy: transcript lands in the wrong config dir

seedIfNeeded iterates candidates, finds the source transcript in configDir[i], and copies it within that same dir. But the resumed Claude process reads from configDirCandidates[0] (the most-specific dir). When these two differ, the copy is placed where Claude will never look and the launch still hits "No conversation found."

Concrete failure: original session used the default ~/.claude (no CLAUDE_CONFIG_DIR in launchEnvironment), but the current cmux process has CLAUDE_CONFIG_DIR=/other in its environment. Candidates become ["/other", "~/.claude"]. The outer loop finds no transcript in /other, then finds the source in ~/.claude and copies it to ~/.claude/projects/<targetName>/. The resumed claude inherits the process environment, uses /other, and still fails.

The fix is to copy into configDirCandidates[0] (or the first writable candidate) regardless of which candidate the source was discovered in.

return false
Comment on lines +87 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Synchronous O(N) project-dir scan on interactive path

contentsOfDirectory lists every entry under projects/, then isFile stat-checks each one, all synchronously inside seedIfNeeded. This is called from forkStartupInput and resumeStartupInput, which are invoked on the main-actor path in Workspace.forkAgentConversation (fork-to-tab, fork-to-split, fork-to-new-workspace) and restoreSessionSnapshot. For a user with hundreds of sessions the scan adds visible latency to every fork and restore. Consider moving the seeding call into an async or off-main helper so the I/O doesn't block the UI thread.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}

/// Session ids are UUID-shaped (hex + dashes). Refusing anything else keeps
/// untrusted ids from path-escaping the projects dir.
private static func isPlausibleSessionId(_ sessionId: String) -> Bool {
!sessionId.isEmpty && sessionId.allSatisfy { character in
character == "-" || (character.isASCII && (character.isLetter || character.isNumber))
}
}

private static func isFile(at url: URL, fileManager: FileManager) -> Bool {
var isDirectory: ObjCBool = false
return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
&& !isDirectory.boolValue
}

private static func isDirectory(at url: URL, fileManager: FileManager) -> Bool {
var isDirectory: ObjCBool = false
return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
&& isDirectory.boolValue
}

/// realpath(3)-style resolution matching Node's `process.cwd()` (keeps the
/// `/private` prefix, unlike `URL.resolvingSymlinksInPath()` which strips
/// it). When the full path does not exist, resolves the deepest existing
/// ancestor and reattaches the remaining components.
private static func symlinkResolvedAbsolutePath(_ path: String) -> String {
let standardized = URL(fileURLWithPath: path).standardized.path
var current = standardized
var unresolvedComponents: [String] = []
while !current.isEmpty, current != "/" {
if let resolved = realpathString(current) {
return unresolvedComponents.reversed().reduce(resolved) { $0 + "/" + $1 }
}
let url = URL(fileURLWithPath: current)
unresolvedComponents.append(url.lastPathComponent)
current = url.deletingLastPathComponent().path
}
return standardized
}

private static func realpathString(_ path: String) -> String? {
guard let resolved = realpath(path, nil) else { return nil }
defer { free(resolved) }
return String(cString: resolved)
}
}
149 changes: 0 additions & 149 deletions Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -709,87 +709,6 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {
registrationOverride: registration
)
}

func resumeStartupInput(
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
allowLauncherScript: Bool = true,
allowOversizedInlineInput: Bool = false
) -> String? {
startupInput(
command: resumeCommand,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory,
allowLauncherScript: allowLauncherScript,
allowOversizedInlineInput: allowOversizedInlineInput
)
}

func resumeStartupCommand(
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> String? {
guard let command = resumeCommand,
let scriptURL = AgentResumeScriptStore.writeLauncherScript(
command: command,
kind: kind,
sessionId: sessionId,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory,
returnToLoginShell: true,
// 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
}
return "/bin/zsh \(shellSingleQuoted(scriptURL.path))"
}

func forkStartupInput(
fileManager: FileManager = .default,
temporaryDirectory: URL = FileManager.default.temporaryDirectory,
allowLauncherScript: Bool = true
) -> String? {
startupInput(
command: forkCommand,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory,
allowLauncherScript: allowLauncherScript
)
}

private func startupInput(
command: String?,
fileManager: FileManager,
temporaryDirectory: URL,
allowLauncherScript: Bool = true,
allowOversizedInlineInput: Bool = false
) -> String? {
guard let command else { return nil }
let inlineInput = command + "\n"
guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else {
return inlineInput
}
guard !allowOversizedInlineInput else {
return inlineInput
}
guard allowLauncherScript else { return nil }
guard let scriptURL = AgentResumeScriptStore.writeLauncherScript(
command: command,
kind: kind,
sessionId: sessionId,
fileManager: fileManager,
temporaryDirectory: temporaryDirectory
) else {
return nil
}

let scriptInput = "/bin/zsh \(shellSingleQuoted(scriptURL.path))\n"
return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil
}
}

extension SessionRestorableAgentSnapshot {
Expand All @@ -802,74 +721,6 @@ extension SessionRestorableAgentSnapshot {
}
}

private enum AgentResumeScriptStore {
private static let directoryName = "cmux-agent-resume"
private static let scriptTTL: TimeInterval = 24 * 60 * 60

static func writeLauncherScript(
command: String,
kind: RestorableAgentKind,
sessionId: String,
fileManager: FileManager,
temporaryDirectory: URL,
returnToLoginShell: Bool = false,
workingDirectory: String? = nil
) -> URL? {
let directoryURL = temporaryDirectory.appendingPathComponent(directoryName, isDirectory: true)
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path)
pruneOldScripts(in: directoryURL, fileManager: fileManager)

let safeSessionPrefix = sessionId
.prefix(12)
.map { character -> Character in
character.isLetter || character.isNumber || character == "-" ? character : "_"
}
let scriptURL = directoryURL.appendingPathComponent(
"\(kind.rawValue)-\(String(safeSessionPrefix))-\(UUID().uuidString).zsh",
isDirectory: false
)
var lines = [
"#!/bin/zsh",
"rm -f -- \"$0\" 2>/dev/null || true"
]
if returnToLoginShell {
lines.append(contentsOf: TerminalStartupReturnShellScript.commandThenReturnLines(
command: command,
workingDirectory: workingDirectory
))
} else {
lines.append(command)
}
let contents = lines.joined(separator: "\n") + "\n"
try contents.write(to: scriptURL, atomically: true, encoding: .utf8)
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: scriptURL.path)
return scriptURL
} catch {
return nil
}
}

private static func pruneOldScripts(in directoryURL: URL, fileManager: FileManager) {
guard let scriptURLs = try? fileManager.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}

let cutoff = Date().addingTimeInterval(-scriptTTL)
for scriptURL in scriptURLs where scriptURL.pathExtension == "zsh" {
let values = try? scriptURL.resourceValues(forKeys: [.contentModificationDateKey])
if let modified = values?.contentModificationDate, modified < cutoff {
try? fileManager.removeItem(at: scriptURL)
}
}
}
}

private struct RestorableAgentHookSessionRecord: Codable, Sendable {
var sessionId: String
var workspaceId: String
Expand Down
Loading
Loading