Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
6 changes: 5 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31322,6 +31322,7 @@ struct CMUXCLI {
sessionId: acceptedSessionId,
cwd: hookCwd,
launchCommand: launchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
)
}
Expand Down Expand Up @@ -31540,6 +31541,7 @@ struct CMUXCLI {
sessionId: sessionId,
cwd: hookCwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
?? mappedSession?.lastPermissionMode
)
Expand Down Expand Up @@ -31752,6 +31754,7 @@ struct CMUXCLI {
sessionId: sessionId,
cwd: hookCwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand,
transcriptPath: hookTranscriptPath,
observedPermissionMode: observedHookPermissionMode
?? mappedSession?.lastPermissionMode
)
Expand Down Expand Up @@ -35830,7 +35833,8 @@ struct CMUXCLI {
let resumeWorkingDirectory = AgentResumeWorkingDirectory().resolve(
kind: kind,
runtimeCwd: cwd,
launchWorkingDirectory: launchCommand?.workingDirectory
launchWorkingDirectory: launchCommand?.workingDirectory,
transcriptPath: transcriptPath
)
guard let command = agentSurfaceResumeCommand(
kind: kind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,29 +41,58 @@ public struct AgentResumeWorkingDirectory: Sendable, Equatable {
///
/// Directory-namespaced agents prefer the launch working directory: it is captured once at launch,
/// matches the session store's namespace, and does not drift when the agent `cd`s into a
/// subdirectory (e.g. a worktree) mid-session. Id-keyed agents keep the runtime cwd so they reopen
/// subdirectory (e.g. a worktree) mid-session. Claude first selects a candidate whose project
/// slug matches the transcript's directory, when available. Id-keyed agents keep the runtime cwd so they reopen
/// where the agent was working. Inputs are trimmed and empty values are treated as absent.
///
/// - Parameters:
/// - kind: the agent's raw kind identifier.
/// - runtimeCwd: the agent's last-reported runtime cwd (may have drifted).
/// - launchWorkingDirectory: the directory the agent was launched in.
/// - transcriptPath: The hook-reported transcript path, when available.
/// - Returns: the directory to `cd` into, or `nil` when neither input is usable.
public func resolve(
kind: String,
runtimeCwd: String?,
launchWorkingDirectory: String?
launchWorkingDirectory: String?,
transcriptPath: String? = nil
) -> String? {
let runtime = normalized(runtimeCwd)
let launch = normalized(launchWorkingDirectory)
switch cwdNamespacing(forKind: kind) {
case .cwdInFile:
return runtime ?? launch
case .byDirectory:
if kind == "claude",
let transcriptProject = transcriptProjectDirectoryName(transcriptPath),
let transcriptCwd = [launch, runtime].compactMap({ $0 }).first(where: {
encodedProjectDirectoryName(forPath: $0) == transcriptProject
}) {
return transcriptCwd
}
return launch ?? runtime
}
}

/// Returns the Claude project-directory component from a transcript path.
/// Claude stores transcripts below `<config>/projects/<encoded-cwd>/`; the
/// session file may be either directly in that directory or nested below it.
private func transcriptProjectDirectoryName(_ transcriptPath: String?) -> String? {
guard let rawPath = normalized(transcriptPath) else { return nil }
let components = (rawPath as NSString).expandingTildeInPath
.split(separator: "/", omittingEmptySubsequences: true)
.map(String.init)
guard let projectsIndex = components.lastIndex(of: "projects"),
projectsIndex + 1 < components.count else {
return nil
}
return components[projectsIndex + 1]
}

private func encodedProjectDirectoryName(forPath path: String) -> String {
ClaudeProjectSlug().slug(forWorkingDirectory: path)
}
Comment thread
austinywang marked this conversation as resolved.
Resolved

private func normalized(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ import Testing

@Suite("AgentResumeWorkingDirectory")
struct AgentResumeWorkingDirectoryTests {
@Test("Claude resumes from the worktree that owns its transcript", arguments: [false, true])
func claudeTranscriptOverridesStaleLaunchDirectory(nested: Bool) {
let sessionID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
let transcript = "/custom/claude/projects/-Users-x-repo--claude-worktrees-feature/"
+ (nested ? "\(sessionID)/messages/" : "") + "\(sessionID).jsonl"
#expect(AgentResumeWorkingDirectory().resolve(
kind: "claude",
runtimeCwd: "/Users/x/repo/.claude/worktrees/feature",
launchWorkingDirectory: "/Users/x/repo",
transcriptPath: transcript
) == "/Users/x/repo/.claude/worktrees/feature")
}

@Test("Claude uses the final projects directory in a nested transcript path")
func claudeTranscriptUsesFinalProjectsDirectory() {
let transcript = "/Users/x/projects/.claude/projects/-Users-x-repo--claude-worktrees-feature/session.jsonl"
#expect(AgentResumeWorkingDirectory().resolve(
kind: "claude",
runtimeCwd: "/Users/x/repo/.claude/worktrees/feature",
launchWorkingDirectory: "/Users/x/repo",
transcriptPath: transcript
) == "/Users/x/repo/.claude/worktrees/feature")
}

@Test("Claude project slugs preserve non-ASCII and punctuation normalization")
func claudeTranscriptMatchesGeneralProjectSlug() {
let transcript = "/custom/claude/projects/-Users-a-my-proj-v2-home--/session.jsonl"
#expect(AgentResumeWorkingDirectory().resolve(
kind: "claude",
runtimeCwd: "/Users/a/my_proj.v2/home/é",
launchWorkingDirectory: "/Users/a",
transcriptPath: transcript
) == "/Users/a/my_proj.v2/home/é")
}

@Test("Directory-namespaced agents pin the launch cwd over a drifted runtime cwd")
func directoryNamespacedPrefersLaunch() {
#expect(
Expand Down
3 changes: 3 additions & 0 deletions Packages/macOS/CmuxWorkspaces/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let package = Package(
),
],
dependencies: [
.package(path: "../CmuxFoundation"),
// WorkspaceGroupNewPlacement (the typed setting value for new
// in-group workspace placement) is owned by CmuxSettings.
.package(path: "../CmuxSettings"),
Expand All @@ -30,6 +31,7 @@ let package = Package(
.target(
name: "CmuxWorkspaces",
dependencies: [
.product(name: "CmuxFoundation", package: "CmuxFoundation"),
.product(name: "CmuxSettings", package: "CmuxSettings"),
.product(name: "Bonsplit", package: "bonsplit"),
.product(name: "CMUXDebugLog", package: "CMUXDebugLog"),
Expand All @@ -46,6 +48,7 @@ let package = Package(
name: "CmuxWorkspacesTests",
dependencies: [
"CmuxWorkspaces",
.product(name: "CmuxFoundation", package: "CmuxFoundation"),
.product(name: "Bonsplit", package: "bonsplit"),
.product(name: "CmuxTestSupport", package: "CmuxTestSupport"),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
public import CmuxFoundation

/// Process observations supplied to ``RestorableAgentProcessMatcher``.
public struct RestorableAgentProcessEvidence: Sendable {
/// The process ID from the restorable session record.
public let processID: Int
/// The process generation saved with the record, when available.
public let recordedIdentity: AgentPIDProcessIdentity?
/// The process generation observed during restore, when available.
public let currentIdentity: AgentPIDProcessIdentity?
/// Whether the process ID is currently occupied.
public let presence: RestorableAgentProcessPresence
/// Whether argv/environment identify the recorded cmux surface, when inspectable.
public let scopeMatches: Bool?
/// Whether argv/environment identify the expected agent session, when inspectable.
public let expectedProcessMatches: Bool?

/// Creates process evidence for one recorded process ID.
///
/// - Parameters:
/// - processID: The PID saved in the session record.
/// - recordedIdentity: The saved generation, or `nil` for a legacy record.
/// - currentIdentity: The observed generation, or `nil` if unreadable.
/// - presence: Whether the PID is currently occupied.
/// - scopeMatches: Whether the process belongs to the saved surface, or `nil` if unreadable.
/// - expectedProcessMatches: Whether the agent and session match, or `nil` if unreadable.
public init(
processID: Int,
recordedIdentity: AgentPIDProcessIdentity?,
currentIdentity: AgentPIDProcessIdentity?,
presence: RestorableAgentProcessPresence,
scopeMatches: Bool?,
expectedProcessMatches: Bool?
) {
self.processID = processID
self.recordedIdentity = recordedIdentity
self.currentIdentity = currentIdentity
self.presence = presence
self.scopeMatches = scopeMatches
self.expectedProcessMatches = expectedProcessMatches
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// Classifies process evidence used by automatic agent-session restore.
public struct RestorableAgentProcessMatcher: Sendable {
/// Creates a stateless process matcher.
public init() {}

/// Returns whether the evidence authenticates the recorded process generation.
///
/// A legacy record without a saved generation never authenticates a matching
/// process, but contradictory argv or scope evidence still proves that a reused
/// PID cannot block restoration.
///
/// - Parameter evidence: Process observations for the saved PID.
/// - Returns: A match only for an authenticated generation, a mismatch for
/// definitive contradictory evidence, or unknown when observations are inconclusive.
public func match(_ evidence: RestorableAgentProcessEvidence) -> RestorableAgentProcessMatch {
if let recordedIdentity = evidence.recordedIdentity {
guard Int(recordedIdentity.pid) == evidence.processID else {
return evidence.presence == .absent ? .mismatches : .unknown
}
guard let currentIdentity = evidence.currentIdentity,
Int(currentIdentity.pid) == evidence.processID else {
return evidence.presence == .absent ? .mismatches : .unknown
}
guard currentIdentity == recordedIdentity else {
return .mismatches
}
} else if evidence.currentIdentity == nil,
evidence.presence == .absent {
return .mismatches
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

if evidence.scopeMatches == false || evidence.expectedProcessMatches == false {
return .mismatches
}
guard evidence.scopeMatches != nil, evidence.expectedProcessMatches != nil else {
return evidence.presence == .absent ? .mismatches : .unknown
}
return evidence.recordedIdentity == nil ? .unknown : .matches
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// Tri-state evidence about whether a recorded process ID is still occupied.
public enum RestorableAgentProcessPresence: Equatable, Hashable, Sendable {
/// The process ID currently exists.
case present
/// The process ID is confirmed absent.
case absent
/// The process table could not establish whether the ID exists.
case unknown
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import CmuxFoundation
import Testing
@testable import CmuxWorkspaces

@Suite("RestorableAgentProcessMatcher")
struct RestorableAgentProcessMatcherTests {
private let matcher = RestorableAgentProcessMatcher()

@Test("A reused legacy PID with contradictory process evidence mismatches", arguments: [false, true])
func reusedLegacyPIDMismatches(hasCurrentIdentity: Bool) {
let result = matcher.match(.init(
processID: 42,
recordedIdentity: nil,
currentIdentity: hasCurrentIdentity ? identity() : nil,
presence: .present,
scopeMatches: true,
expectedProcessMatches: false
))
#expect(result == .mismatches)
}

@Test("A different surface proves a legacy PID is reused even without agent details")
func reusedLegacyPIDInAnotherScopeMismatches() {
#expect(matcher.match(.init(
processID: 42,
recordedIdentity: nil,
currentIdentity: nil,
presence: .present,
scopeMatches: false,
expectedProcessMatches: nil
)) == .mismatches)
}

@Test("A matching legacy PID remains unknown without a saved generation", arguments: [false, true])
func matchingLegacyPIDRemainsUnknown(hasCurrentIdentity: Bool) {
let result = matcher.match(.init(
processID: 42,
recordedIdentity: nil,
currentIdentity: hasCurrentIdentity ? identity() : nil,
presence: .present,
scopeMatches: true,
expectedProcessMatches: true
))
#expect(result == .unknown)
}

@Test("A matching saved process generation authenticates")
func matchingSavedGenerationAuthenticates() {
let identity = identity()
let result = matcher.match(.init(
processID: 42,
recordedIdentity: identity,
currentIdentity: identity,
presence: .present,
scopeMatches: true,
expectedProcessMatches: true
))
#expect(result == .matches)
}

@Test("A changed process generation proves PID reuse even when argv is unreadable")
func changedGenerationMismatches() {
#expect(matcher.match(.init(
processID: 42,
recordedIdentity: identity(),
currentIdentity: identity(startSeconds: 20),
presence: .present,
scopeMatches: nil,
expectedProcessMatches: nil
)) == .mismatches)
}

@Test("Unreadable processes remain uncertain unless their absence is definitive",
arguments: [RestorableAgentProcessPresence.present, .absent, .unknown], [false, true])
func unreadableProcessRequiresDefinitiveAbsence(
presence: RestorableAgentProcessPresence,
hasRecordedIdentity: Bool
) {
#expect(matcher.match(.init(
processID: 42,
recordedIdentity: hasRecordedIdentity ? identity() : nil,
currentIdentity: nil,
presence: presence,
scopeMatches: nil,
expectedProcessMatches: nil
)) == (presence == .absent ? .mismatches : .unknown))
}

@Test("A saved generation cannot authenticate without a matching current identity")
func missingCurrentGenerationStaysUnknown() {
#expect(matcher.match(.init(
processID: 42,
recordedIdentity: identity(),
currentIdentity: nil,
presence: .present,
scopeMatches: true,
expectedProcessMatches: true
)) == .unknown)
}

private func identity(startSeconds: Int64 = 10) -> AgentPIDProcessIdentity {
AgentPIDProcessIdentity(pid: 42, startSeconds: startSeconds, startMicroseconds: 2)
}
}
Loading
Loading