Repository navigation
Fix Cmd-Shift-P forks across workspace directories #16272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+420
−0
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
ac8a72d
test: cover Claude cross-directory fork transcript seeding
austinywang 72a18e8
fix: seed Claude transcripts before cross-directory forks
austinywang 6f64735
test: fix fork transcript fixture literal
austinywang 2ba7899
fix: harden Claude fork transcript seeding
austinywang 4021751
fix: make Claude transcript seeder a service
austinywang 383d482
fix: support Claude nested transcript layouts
austinywang 5e3d698
fix: close Claude seeder review gaps
austinywang 5b5f539
test: cover Claude fork CLI wiring
austinywang ae7f316
fix: correct Claude fork fixture interpolation
austinywang 787a1b8
Fix Claude fork transcript destination
austinywang a129889
fix Claude forks across config directories
austinywang e24a1fc
avoid sorting Claude project fallback scan
austinywang e3696c9
fix nested Codex fork watcher propagation
austinywang adada74
test Codex nested fork monitor handoff
austinywang b6b24e2
Wire Codex fork monitor tests into Xcode
austinywang 8ca4056
Merge remote-tracking branch 'origin/main' into issue-16269-cmd-shift…
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/ClaudeTranscriptForkSeeder.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
|
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" | ||
|
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) | ||
| } | ||
|
|
||
| } | ||
120 changes: 120 additions & 0 deletions
120
...es/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/ClaudeTranscriptForkSeederTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.