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
46 changes: 46 additions & 0 deletions CLI/CMUXCLI+Fork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ extension CMUXCLI {
} else {
nil
}
let transcriptTargetWorkingDirectory = normalizedRestoreWorkingDirectory(
record.forkArgumentsWorkingDirectory
) ?? effectiveWorkingDirectory
try await seedClaudeTranscriptForForkIfNeeded(
record: record,
targetWorkingDirectory: transcriptTargetWorkingDirectory,
processEnvironment: processEnvironment
)
let request = AgentRestoreRequest(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
mode: .forkAgent,
kind: record.kind,
Expand Down Expand Up @@ -289,6 +297,44 @@ extension CMUXCLI {
)
}

/// Prepares provider state required for a Claude fork without exposing filesystem details to users.
private func seedClaudeTranscriptForForkIfNeeded(
record: RestoreRecord,
targetWorkingDirectory: String?,
processEnvironment: [String: String]
) async throws {
guard record.kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "claude",
let sessionID = record.checkpointID?.trimmingCharacters(in: .whitespacesAndNewlines),
!sessionID.isEmpty,
let targetWorkingDirectory = targetWorkingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines),
!targetWorkingDirectory.isEmpty else { return }

let launchEnvironment = record.launchCommand?.environment ?? [:]
let rawConfigRoot = record.environment["CLAUDE_CONFIG_DIR"]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
?? launchEnvironment["CLAUDE_CONFIG_DIR"]
?? processEnvironment["CLAUDE_CONFIG_DIR"]
?? ((NSHomeDirectory() as NSString).appendingPathComponent(".claude"))
let configRoot = ClaudeConfigDirectoryPath.preferredPath(rawConfigRoot)
let request = ClaudeTranscriptForkSeedRequest(
sessionID: sessionID,
sourceWorkingDirectory: record.launchCommand?.workingDirectory ?? record.workingDirectory,
targetWorkingDirectory: targetWorkingDirectory,
configDirectory: configRoot,
sourceConfigDirectories: [
((NSHomeDirectory() as NSString).appendingPathComponent(".claude"))
]
)
do {
try await ClaudeTranscriptForkSeeder().seed(request)
} catch {
throw loggedForkError(
.providerSetupFailed,
stage: "provider.transcript-seed",
detail: String(reflecting: type(of: error))
)
}
}

private func legacyForkCommand(for record: RestoreRecord) -> String? {
if let explicit = record.legacyForkCommand?.trimmingCharacters(in: .whitespacesAndNewlines),
!explicit.isEmpty {
Expand Down
19 changes: 19 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31757,6 +31757,12 @@ struct CMUXCLI {
"--session",
sessionId,
]
// A cmux-created Codex fork carries its parent and launch claim in the
// environment. Forward them to the detached monitor so it can watch
// the owner process for the child rollout and publish the child hook
// binding. Without this, the first fork may render, but a fork of that
// child has no durable parent association to discover.
monitorArgs += Self.codexForkMonitorArguments(environment: env)
if let surfaceId, !surfaceId.isEmpty {
monitorArgs += ["--surface", surfaceId]
}
Expand Down Expand Up @@ -31785,6 +31791,19 @@ struct CMUXCLI {
}
}

static func codexForkMonitorArguments(environment: [String: String]) -> [String] {
guard let forkParent = environment[CodexForkSessionWatcher.parentSessionEnvironmentKey],
!forkParent.isEmpty else { return [] }
var arguments = ["--fork-parent", forkParent]
if let launchID = environment[CodexForkSessionWatcher.launchIDEnvironmentKey], !launchID.isEmpty {
arguments += ["--fork-launch-id", launchID]
}
if let ownerPID = environment["CMUX_CODEX_PID"], !ownerPID.isEmpty {
arguments += ["--fork-owner-pid", ownerPID]
}
return arguments
}

/// Watches the Codex rollout until the turn settles.
///
/// Returns the Stop replay for a healthy completion instead of running it,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Foundation

/// The filesystem inputs needed to make a Claude transcript resumable in a new project directory.
public struct ClaudeTranscriptForkSeedRequest: Sendable {
public let sessionID: String
public let sourceWorkingDirectory: String?
public let targetWorkingDirectory: String
public let configDirectory: String
public let sourceConfigDirectories: [String]

public init(
sessionID: String,
sourceWorkingDirectory: String?,
targetWorkingDirectory: String,
configDirectory: String,
sourceConfigDirectories: [String] = []
) {
self.sessionID = sessionID
self.sourceWorkingDirectory = sourceWorkingDirectory
self.targetWorkingDirectory = targetWorkingDirectory
self.configDirectory = configDirectory
self.sourceConfigDirectories = sourceConfigDirectories
}
}

/// Copies Claude's transcript and sidecar into a destination project before a fork launches.
public struct ClaudeTranscriptForkSeeder: Sendable {
public init() {}
/// Performs discovery and copying off the caller's executor, and repairs a missing sidecar on retry.
public func seed(_ request: ClaudeTranscriptForkSeedRequest) async throws {
try await Task.detached(priority: .userInitiated) {
try Self.seedSynchronously(request)
}.value
}

private static func seedSynchronously(_ request: ClaudeTranscriptForkSeedRequest) throws {
guard request.sessionID.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
request.configDirectory.hasPrefix("/"),
!request.targetWorkingDirectory.isEmpty else { return }

let fileManager = FileManager.default
let projectsRoot = (request.configDirectory as NSString).appendingPathComponent("projects")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let targetProject = (projectsRoot as NSString).appendingPathComponent(
ClaudeProjectSlug().slug(forWorkingDirectory: request.targetWorkingDirectory)
)
let sourceRoots = ([request.configDirectory] + request.sourceConfigDirectories)
.filter { $0.hasPrefix("/") }
.reduce(into: [String]()) { roots, root in
if !roots.contains(root) { roots.append(root) }
}
let sourceTranscript = sourceRoots.lazy.compactMap { root in
findSourceTranscript(
sessionID: request.sessionID,
sourceWorkingDirectory: request.sourceWorkingDirectory,
projectsRoot: (root as NSString).appendingPathComponent("projects"),
fileManager: fileManager
)
}.first
guard let sourceTranscript else { return }
let targetTranscript = (targetProject as NSString).appendingPathComponent(sourceTranscript.relativePath)
let targetSidecar = (targetProject as NSString).appendingPathComponent(request.sessionID)

let sourceSidecar = (sourceTranscript.projectPath as NSString).appendingPathComponent(request.sessionID)
var sourceSidecarIsDirectory: ObjCBool = false
let hasSourceSidecar = fileManager.fileExists(
atPath: sourceSidecar,
isDirectory: &sourceSidecarIsDirectory
) && sourceSidecarIsDirectory.boolValue
let hasTargetTranscript = fileManager.fileExists(atPath: targetTranscript)
let hasTargetSidecar = fileManager.fileExists(atPath: targetSidecar)
guard !hasTargetTranscript || (hasSourceSidecar && !hasTargetSidecar) else { return }

try fileManager.createDirectory(atPath: targetProject, withIntermediateDirectories: true)
if hasSourceSidecar && !hasTargetSidecar {
try copyAtomically(sourceSidecar, to: targetSidecar, fileManager: fileManager)
}
if !hasTargetTranscript && !fileManager.fileExists(atPath: targetTranscript) {
try copyAtomically(sourceTranscript.path, to: targetTranscript, fileManager: fileManager)
}
}

private static func findSourceTranscript(
sessionID: String,
sourceWorkingDirectory: String?,
projectsRoot: String,
fileManager: FileManager
) -> TranscriptLocation? {
if let sourceWorkingDirectory {
let sourceProject = (projectsRoot as NSString).appendingPathComponent(
ClaudeProjectSlug().slug(forWorkingDirectory: sourceWorkingDirectory)
)
if let location = transcriptLocation(
projectPath: sourceProject, sessionID: sessionID, fileManager: fileManager
) { return location }
}
guard let projectNames = try? fileManager.contentsOfDirectory(atPath: projectsRoot) else { return nil }
for projectName in projectNames {
let projectPath = (projectsRoot as NSString).appendingPathComponent(projectName)
if let location = transcriptLocation(
projectPath: projectPath, sessionID: sessionID, fileManager: fileManager
) { return location }
}
return nil
}

private static func transcriptLocation(
projectPath: String, sessionID: String, fileManager: FileManager
) -> TranscriptLocation? {
let candidates = [
"\(sessionID).jsonl",
"\(sessionID)/messages/\(sessionID).jsonl"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
]
for relativePath in candidates {
let path = (projectPath as NSString).appendingPathComponent(relativePath)
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue,
(try? fileManager.attributesOfItem(atPath: path)[.type] as? FileAttributeType) == .typeRegular
else { continue }
return TranscriptLocation(path: path, projectPath: projectPath, relativePath: relativePath)
}
return nil
}

private struct TranscriptLocation {
let path: String
let projectPath: String
let relativePath: String
}

private static func copyAtomically(_ source: String, to destination: String, fileManager: FileManager) throws {
let destinationDirectory = (destination as NSString).deletingLastPathComponent
try fileManager.createDirectory(atPath: destinationDirectory, withIntermediateDirectories: true)
let temporaryDestination = "\(destination).tmp-\(UUID().uuidString)"
defer { try? fileManager.removeItem(atPath: temporaryDestination) }
try fileManager.copyItem(atPath: source, toPath: temporaryDestination)
try fileManager.moveItem(atPath: temporaryDestination, toPath: destination)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import Testing
@testable import CMUXAgentLaunch

@Suite
struct ClaudeTranscriptForkSeederTests {
@Test
func copiesTranscriptAndRepairsMissingSidecar() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-claude-seeder-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
let config = root.appendingPathComponent("config")
let source = root.appendingPathComponent("source")
let destination = root.appendingPathComponent("destination")
let sessionID = "seed-session"
let encodedSource = ClaudeProjectSlug().slug(forWorkingDirectory: source.path)
let encodedDestination = ClaudeProjectSlug().slug(forWorkingDirectory: destination.path)
let sourceProject = config.appendingPathComponent("projects").appendingPathComponent(encodedSource)
try FileManager.default.createDirectory(at: sourceProject, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let sourceTranscript = sourceProject.appendingPathComponent("\(sessionID).jsonl")
try Data("{\"type\":\"user\"}\n".utf8).write(to: sourceTranscript)
let sourceSidecar = sourceProject.appendingPathComponent(sessionID)
try FileManager.default.createDirectory(at: sourceSidecar, withIntermediateDirectories: true)
try Data("{\"state\":\"fixture\"}".utf8)
.write(to: sourceSidecar.appendingPathComponent("state.json"))

let request = ClaudeTranscriptForkSeedRequest(
sessionID: sessionID,
sourceWorkingDirectory: source.path,
targetWorkingDirectory: destination.path,
configDirectory: config.path
)
try await ClaudeTranscriptForkSeeder().seed(request)
let targetProject = config.appendingPathComponent("projects").appendingPathComponent(encodedDestination)
let targetTranscript = targetProject.appendingPathComponent("\(sessionID).jsonl")
let targetSidecarFile = targetProject.appendingPathComponent(sessionID).appendingPathComponent("state.json")
let copiedTranscript = try Data(contentsOf: targetTranscript)
let sourceTranscriptData = try Data(contentsOf: sourceTranscript)
let copiedSidecar = try Data(contentsOf: targetSidecarFile)
let sourceSidecarData = try Data(contentsOf: sourceSidecar.appendingPathComponent("state.json"))
#expect(copiedTranscript == sourceTranscriptData)
#expect(copiedSidecar == sourceSidecarData)

try FileManager.default.removeItem(at: targetProject.appendingPathComponent(sessionID))
try await ClaudeTranscriptForkSeeder().seed(request)
#expect(FileManager.default.fileExists(atPath: targetSidecarFile.path))
}
}

extension ClaudeTranscriptForkSeederTests {
@Test
func copiesFromFallbackConfigDirectoryIntoLaunchConfig() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-claude-seeder-fallback-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
let sourceConfig = root.appendingPathComponent("source-config")
let launchConfig = root.appendingPathComponent("launch-config")
let source = root.appendingPathComponent("source")
let destination = root.appendingPathComponent("destination")
let sessionID = "fallback-session"
let sourceProject = sourceConfig.appendingPathComponent("projects")
.appendingPathComponent(ClaudeProjectSlug().slug(forWorkingDirectory: source.path))
try FileManager.default.createDirectory(at: sourceProject, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let sourceTranscript = sourceProject.appendingPathComponent("\(sessionID).jsonl")
try Data("{\"type\":\"user\"}\n".utf8).write(to: sourceTranscript)

try await ClaudeTranscriptForkSeeder().seed(ClaudeTranscriptForkSeedRequest(
sessionID: sessionID,
sourceWorkingDirectory: source.path,
targetWorkingDirectory: destination.path,
configDirectory: launchConfig.path,
sourceConfigDirectories: [sourceConfig.path]
))

let targetTranscript = launchConfig.appendingPathComponent("projects")
.appendingPathComponent(ClaudeProjectSlug().slug(forWorkingDirectory: destination.path))
.appendingPathComponent("\(sessionID).jsonl")
#expect(try Data(contentsOf: targetTranscript) == Data(contentsOf: sourceTranscript))
}

@Test
func findsNestedTranscriptWithClaudeSlugAndRejectsDirectoryCandidate() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-claude-seeder-nested-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
let config = root.appendingPathComponent("config")
let source = root.appendingPathComponent("source folder/é")
let destination = root.appendingPathComponent("destination folder/é")
let sessionID = "nested-session"
let sourceProject = config.appendingPathComponent("projects")
.appendingPathComponent(ClaudeProjectSlug().slug(forWorkingDirectory: source.path))
let nestedDirectory = sourceProject.appendingPathComponent(sessionID).appendingPathComponent("messages")
try FileManager.default.createDirectory(at: nestedDirectory, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: sourceProject.appendingPathComponent("\(sessionID).jsonl"), withIntermediateDirectories: true
)
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let sourceTranscript = nestedDirectory.appendingPathComponent("\(sessionID).jsonl")
try Data("{\"type\":\"nested\"}\n".utf8).write(to: sourceTranscript)

try await ClaudeTranscriptForkSeeder().seed(ClaudeTranscriptForkSeedRequest(
sessionID: sessionID,
sourceWorkingDirectory: source.path,
targetWorkingDirectory: destination.path,
configDirectory: config.path
))

let targetTranscript = config.appendingPathComponent("projects")
.appendingPathComponent(ClaudeProjectSlug().slug(forWorkingDirectory: destination.path))
.appendingPathComponent(sessionID).appendingPathComponent("messages/\(sessionID).jsonl")
let copied = try Data(contentsOf: targetTranscript)
let sourceData = try Data(contentsOf: sourceTranscript)
#expect(copied == sourceData)
}
}
Loading
Loading