diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 207848a53ad7..da1e89396de9 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -289,6 +289,7 @@ enum AgentResumeCommandBuilder { sessionId: String, launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, + transcriptPath: String? = nil, registrationOverride: CmuxVaultAgentRegistration? = nil, includeWorkingDirectoryPrefix: Bool = true ) -> String? { @@ -308,8 +309,10 @@ enum AgentResumeCommandBuilder { return shellCommand( argv: argv, kind: kind, + sessionId: sessionId, launchCommand: launchCommand, workingDirectory: workingDirectory, + transcriptPath: transcriptPath, customRegistration: customRegistration, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix ) @@ -320,6 +323,7 @@ enum AgentResumeCommandBuilder { sessionId: String, launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, + transcriptPath: String? = nil, registrationOverride: CmuxVaultAgentRegistration? = nil, includeWorkingDirectoryPrefix: Bool = true ) -> String? { @@ -337,8 +341,10 @@ enum AgentResumeCommandBuilder { return shellCommand( argv: argv, kind: kind, + sessionId: sessionId, launchCommand: launchCommand, workingDirectory: workingDirectory, + transcriptPath: transcriptPath, customRegistration: customRegistration, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix ) @@ -347,8 +353,10 @@ enum AgentResumeCommandBuilder { private static func shellCommand( argv: [String], kind: RestorableAgentKind, + sessionId: String, launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, + transcriptPath: String?, customRegistration: CmuxVaultAgentRegistration?, includeWorkingDirectoryPrefix: Bool ) -> String { @@ -360,9 +368,15 @@ enum AgentResumeCommandBuilder { } commandParts.append(contentsOf: argv) - let cwd = !includeWorkingDirectoryPrefix || customRegistration?.cwd == .ignore - ? nil - : normalized(workingDirectory ?? launchCommand?.workingDirectory) + let cwd = shellWorkingDirectory( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + transcriptPath: transcriptPath, + customRegistration: customRegistration, + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + ) let sanitizedCommandParts = customRegistration == nil ? AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: commandParts, @@ -373,6 +387,63 @@ enum AgentResumeCommandBuilder { return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } + private static func shellWorkingDirectory( + kind: RestorableAgentKind, + sessionId: String, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + transcriptPath: String?, + customRegistration: CmuxVaultAgentRegistration?, + includeWorkingDirectoryPrefix: Bool + ) -> String? { + guard includeWorkingDirectoryPrefix, customRegistration?.cwd != .ignore else { + return nil + } + let fallback = normalized(workingDirectory ?? launchCommand?.workingDirectory) + guard kind == .claude, + let projectDirName = claudeTranscriptProjectDirName( + transcriptPath: transcriptPath, + sessionId: sessionId + ) else { + return fallback + } + + let candidates = [workingDirectory, launchCommand?.workingDirectory] + for candidate in candidates { + guard let cwd = normalized(candidate) else { continue } + if RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd) == projectDirName { + return cwd + } + let expanded = (cwd as NSString).expandingTildeInPath + if expanded != cwd, RestorableAgentSessionIndex.encodeClaudeProjectDir(expanded) == projectDirName { + return expanded + } + } + + return fallback + } + + private static func claudeTranscriptProjectDirName( + transcriptPath: String?, + sessionId: String + ) -> String? { + guard let transcriptPath = normalized(transcriptPath), + !sessionId.isEmpty else { + return nil + } + let expanded = (transcriptPath as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded, isDirectory: false) + guard url.lastPathComponent == "\(sessionId).jsonl" else { + return nil + } + let projectDirectory = url.deletingLastPathComponent() + guard projectDirectory.deletingLastPathComponent().lastPathComponent == "projects" else { + return nil + } + let name = projectDirectory.lastPathComponent + return name.isEmpty ? nil : name + } + static func openCodeVersionProbe( launchCommand: AgentLaunchCommandSnapshot? ) -> (executable: String, arguments: [String])? { @@ -803,6 +874,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { var sessionId: String var workingDirectory: String? var launchCommand: AgentLaunchCommandSnapshot? + var transcriptPath: String? = nil var registration: CmuxVaultAgentRegistration? = nil var resumeCommand: String? { @@ -811,6 +883,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { sessionId: sessionId, launchCommand: launchCommand, workingDirectory: workingDirectory, + transcriptPath: transcriptPath, registrationOverride: registration ) } @@ -821,6 +894,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { sessionId: sessionId, launchCommand: launchCommand, workingDirectory: workingDirectory, + transcriptPath: transcriptPath, registrationOverride: registration ) } @@ -1136,6 +1210,7 @@ struct RestorableAgentSessionIndex: Sendable { sessionId: normalizedSessionId, workingDirectory: normalizedWorkingDirectory(record.cwd), launchCommand: record.launchCommand, + transcriptPath: normalizedNonEmptyValue(record.transcriptPath), registration: registration ) let key = PanelKey(workspaceId: workspaceId, panelId: panelId) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 08fb9c53a00f..a9e1a64da005 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -872,13 +872,6 @@ final class SessionIndexStore: ObservableObject { ?? url.deletingLastPathComponent().lastPathComponent } - /// Inverse of `decodeClaudeProjectDir`. Used as a fast path: when filtering - /// by cwd we can skip enumerating other project dirs entirely. - nonisolated private static func encodeClaudeProjectDir(_ path: String) -> String { - // "/Users/x/y" -> "-Users-x-y" - return path.replacingOccurrences(of: "/", with: "-") - } - nonisolated private static func enumerateClaudeJSONLCandidates( root: ClaudeSessionRoot, cwdFilter: String?, @@ -907,7 +900,7 @@ final class SessionIndexStore: ObservableObject { } if let cwdFilter { - let dirName = encodeClaudeProjectDir(cwdFilter) + let dirName = RestorableAgentSessionIndex.encodeClaudeProjectDir(cwdFilter) let dirPath = (root.projectsRoot as NSString).appendingPathComponent(dirName) var isDir: ObjCBool = false if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue { diff --git a/cmuxTests/RestorableAgentSessionIndexTests.swift b/cmuxTests/RestorableAgentSessionIndexTests.swift index dcb6a92ac8b2..fd995f8a9e5d 100644 --- a/cmuxTests/RestorableAgentSessionIndexTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexTests.swift @@ -135,6 +135,108 @@ final class RestorableAgentSessionIndexTests: XCTestCase { ) } + func testClaudeForkCommandUsesTranscriptProjectWorkingDirectoryWhenHookCwdDrifts() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent("cmux-claude-fork-cwd-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let configDir = root.appendingPathComponent("claude-config", isDirectory: true) + let projectsDir = configDir.appendingPathComponent("projects", isDirectory: true) + let projectCwd = root.appendingPathComponent("cmuxterm-hq", isDirectory: true) + let hookCwd = projectCwd + .appendingPathComponent("worktrees", isDirectory: true) + .appendingPathComponent("feat-ios-swift-mobile-core", isDirectory: true) + try fm.createDirectory(at: hookCwd, withIntermediateDirectories: true) + + let sessionId = "11111111-2222-3333-4444-555555555555" + let transcriptURL = projectsDir + .appendingPathComponent(RestorableAgentSessionIndex.encodeClaudeProjectDir(projectCwd.path), isDirectory: true) + .appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + try writeClaudeTranscript(sessionId: sessionId, transcriptURL: transcriptURL, cwd: projectCwd) + + let workspaceId = UUID() + let panelId = UUID() + try writeClaudeHookStore( + root: root, + sessions: [ + sessionId: hookRecord( + sessionId: sessionId, + workspaceId: workspaceId, + panelId: panelId, + cwd: hookCwd.path, + configDir: configDir.path, + transcriptPath: transcriptURL.path, + isRestorable: nil, + updatedAt: 20, + launchWorkingDirectory: projectCwd.path + ), + ] + ) + + let snapshot = try XCTUnwrap( + RestorableAgentSessionIndex.load(homeDirectory: root.path, fileManager: fm) + .snapshot(workspaceId: workspaceId, panelId: panelId) + ) + + XCTAssertEqual(snapshot.workingDirectory, hookCwd.path) + XCTAssertEqual( + snapshot.forkCommand, + "{ cd -- '\(projectCwd.path)' 2>/dev/null || [ ! -d '\(projectCwd.path)' ]; } && 'env' 'CLAUDE_CONFIG_DIR=\(configDir.path)' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1' 'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=CLAUDE_CONFIG_DIR' '/usr/local/bin/claude' '--resume' '\(sessionId)' '--fork-session' '--dangerously-skip-permissions'" + ) + XCTAssertFalse( + snapshot.forkCommand?.contains("{ cd -- '\(hookCwd.path)'") ?? false, + "Claude must fork from the transcript project directory, not the later hook cwd, or Claude cannot find the conversation." + ) + } + + func testClaudeForkCommandDoesNotLossilyDecodeHyphenatedTranscriptProjectDirectory() throws { + let fm = FileManager.default + let root = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent( + "cmuxclaudehyphen\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))", + isDirectory: true + ) + defer { try? fm.removeItem(at: root) } + + let projectCwd = root.appendingPathComponent("my-project", isDirectory: true) + let hookCwd = root.appendingPathComponent("drifted-cwd", isDirectory: true) + try fm.createDirectory(at: projectCwd, withIntermediateDirectories: true) + try fm.createDirectory(at: hookCwd, withIntermediateDirectories: true) + + let sessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + let encodedProjectDir = RestorableAgentSessionIndex.encodeClaudeProjectDir(projectCwd.path) + let transcriptURL = root + .appendingPathComponent("claude-config", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent(encodedProjectDir, isDirectory: true) + .appendingPathComponent("\(sessionId).jsonl", isDirectory: false) + + let lossyDecodedPath = "/" + String(encodedProjectDir.dropFirst()) + .replacingOccurrences(of: "-", with: "/") + try fm.createDirectory( + at: URL(fileURLWithPath: lossyDecodedPath, isDirectory: true), + withIntermediateDirectories: true + ) + + let command = AgentResumeCommandBuilder.forkShellCommand( + kind: .claude, + sessionId: sessionId, + launchCommand: nil, + workingDirectory: hookCwd.path, + transcriptPath: transcriptURL.path + ) + + XCTAssertEqual( + command, + "{ cd -- '\(hookCwd.path)' 2>/dev/null || [ ! -d '\(hookCwd.path)' ]; } && 'claude' '--resume' '\(sessionId)' '--fork-session'" + ) + XCTAssertFalse( + command?.contains("{ cd -- '\(lossyDecodedPath)'") ?? false, + "Claude transcript project directory names cannot be decoded by replacing every hyphen with a slash." + ) + } + func testPanelFallbackUsesLatestHookRecord() throws { let fm = FileManager.default let root = fm.temporaryDirectory @@ -247,7 +349,8 @@ final class RestorableAgentSessionIndexTests: XCTestCase { configDir: String, transcriptPath: String?, isRestorable: Bool?, - updatedAt: TimeInterval + updatedAt: TimeInterval, + launchWorkingDirectory: String? = nil ) -> [String: Any] { var record: [String: Any] = [ "sessionId": sessionId, @@ -260,7 +363,7 @@ final class RestorableAgentSessionIndexTests: XCTestCase { "launcher": "claude", "executablePath": "/usr/local/bin/claude", "arguments": ["/usr/local/bin/claude", "--dangerously-skip-permissions"], - "workingDirectory": cwd, + "workingDirectory": launchWorkingDirectory ?? cwd, "environment": ["CLAUDE_CONFIG_DIR": configDir], "capturedAt": updatedAt, "source": "test", @@ -283,6 +386,10 @@ final class RestorableAgentSessionIndexTests: XCTestCase { } private func writeClaudeTranscript(sessionId: String, transcriptURL: URL, cwd: URL) throws { + try FileManager.default.createDirectory( + at: transcriptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) try """ {"type":"last-prompt","sessionId":"\(sessionId)"} {"type":"user","sessionId":"\(sessionId)","cwd":"\(cwd.path)","message":{"role":"user","content":"hello"}}