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
81 changes: 78 additions & 3 deletions Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ enum AgentResumeCommandBuilder {
sessionId: String,
launchCommand: AgentLaunchCommandSnapshot?,
workingDirectory: String?,
transcriptPath: String? = nil,
registrationOverride: CmuxVaultAgentRegistration? = nil,
includeWorkingDirectoryPrefix: Bool = true
) -> String? {
Expand All @@ -308,8 +309,10 @@ enum AgentResumeCommandBuilder {
return shellCommand(
argv: argv,
kind: kind,
sessionId: sessionId,
launchCommand: launchCommand,
workingDirectory: workingDirectory,
transcriptPath: transcriptPath,
customRegistration: customRegistration,
includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix
)
Expand All @@ -320,6 +323,7 @@ enum AgentResumeCommandBuilder {
sessionId: String,
launchCommand: AgentLaunchCommandSnapshot?,
workingDirectory: String?,
transcriptPath: String? = nil,
registrationOverride: CmuxVaultAgentRegistration? = nil,
includeWorkingDirectoryPrefix: Bool = true
) -> String? {
Expand All @@ -337,8 +341,10 @@ enum AgentResumeCommandBuilder {
return shellCommand(
argv: argv,
kind: kind,
sessionId: sessionId,
launchCommand: launchCommand,
workingDirectory: workingDirectory,
transcriptPath: transcriptPath,
customRegistration: customRegistration,
includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix
)
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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])? {
Expand Down Expand Up @@ -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? {
Expand All @@ -811,6 +883,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {
sessionId: sessionId,
launchCommand: launchCommand,
workingDirectory: workingDirectory,
transcriptPath: transcriptPath,
registrationOverride: registration
)
}
Expand All @@ -821,6 +894,7 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable {
sessionId: sessionId,
launchCommand: launchCommand,
workingDirectory: workingDirectory,
transcriptPath: transcriptPath,
registrationOverride: registration
)
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 1 addition & 8 deletions Sources/SessionIndexStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 109 additions & 2 deletions cmuxTests/RestorableAgentSessionIndexTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Create the Claude projects directory before writing

This new test builds transcriptURL under claude-config/projects/<encoded cwd>/..., but the only directory created beforehand is hookCwd under the project tree. Unlike the neighboring tests, nothing creates projectsDir or the encoded project directory, so writeClaudeTranscript(..., transcriptURL: ...) fails with a missing-parent-directory error before any assertions run, making the test suite fail whenever this test is executed.

Useful? React with 👍 / 👎.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

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
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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"}}
Expand Down
Loading